diff --git a/.changeset/config.json b/.changeset/config.json index 5cb30af19287e..002fec744b8ad 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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 } - } diff --git a/.changeset/hip-squids-return.md b/.changeset/hip-squids-return.md new file mode 100644 index 0000000000000..39e213e6df208 --- /dev/null +++ b/.changeset/hip-squids-return.md @@ -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. diff --git a/.eslintrc.js b/.eslintrc.js index 1b7707a9e0490..8c3962e6abc81 100644 --- a/.eslintrc.js +++ b/.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", diff --git a/package.json b/package.json index 211a3526df6b1..336ea9e9a3681 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/admin-next/admin-bundler/README.md b/packages/admin-next/admin-bundler/README.md new file mode 100644 index 0000000000000..ecfe9dffd9843 --- /dev/null +++ b/packages/admin-next/admin-bundler/README.md @@ -0,0 +1 @@ +# cli diff --git a/packages/admin-next/admin-bundler/bin/medusa-admin.js b/packages/admin-next/admin-bundler/bin/medusa-admin.js new file mode 100755 index 0000000000000..05470b3f67b31 --- /dev/null +++ b/packages/admin-next/admin-bundler/bin/medusa-admin.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +function start() { + return import("../dist/cli/index.mjs"); +} + +start(); diff --git a/packages/admin-next/admin-bundler/package.json b/packages/admin-next/admin-bundler/package.json new file mode 100644 index 0000000000000..4d247d95bf8e4 --- /dev/null +++ b/packages/admin-next/admin-bundler/package.json @@ -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" +} diff --git a/packages/admin-next/admin-bundler/src/api/build.ts b/packages/admin-next/admin-bundler/src/api/build.ts new file mode 100644 index 0000000000000..ed64b7720fae6 --- /dev/null +++ b/packages/admin-next/admin-bundler/src/api/build.ts @@ -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) +} diff --git a/packages/admin-next/admin-bundler/src/api/bundle.ts b/packages/admin-next/admin-bundler/src/api/bundle.ts new file mode 100644 index 0000000000000..b37706bce2394 --- /dev/null +++ b/packages/admin-next/admin-bundler/src/api/bundle.ts @@ -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 = {} + 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, + }, + }, + }) +} diff --git a/packages/admin-next/admin-bundler/src/api/create-vite-config.ts b/packages/admin-next/admin-bundler/src/api/create-vite-config.ts new file mode 100644 index 0000000000000..89fc8e9a81cfd --- /dev/null +++ b/packages/admin-next/admin-bundler/src/api/create-vite-config.ts @@ -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 { + 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 { + enableUI?: boolean +} + +type Plugin = + | string + | { + resolve: string + options?: PluginOptions + } + +interface MedusaConfig extends Record { + 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) + | undefined + + return { + plugins: extensionSources, + userConfig: viteConfig, + } +} diff --git a/packages/admin-next/admin-bundler/src/api/dev.ts b/packages/admin-next/admin-bundler/src/api/dev.ts new file mode 100644 index 0000000000000..74eb7870ef2d0 --- /dev/null +++ b/packages/admin-next/admin-bundler/src/api/dev.ts @@ -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 }) +} diff --git a/packages/admin-next/admin-bundler/src/cli/create-cli.ts b/packages/admin-next/admin-bundler/src/cli/create-cli.ts new file mode 100644 index 0000000000000..b7827cff0121e --- /dev/null +++ b/packages/admin-next/admin-bundler/src/cli/create-cli.ts @@ -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 +} diff --git a/packages/admin-next/admin-bundler/src/cli/index.ts b/packages/admin-next/admin-bundler/src/cli/index.ts new file mode 100644 index 0000000000000..fd04d6859b570 --- /dev/null +++ b/packages/admin-next/admin-bundler/src/cli/index.ts @@ -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) + }) diff --git a/packages/admin-next/admin-bundler/src/index.ts b/packages/admin-next/admin-bundler/src/index.ts new file mode 100644 index 0000000000000..d1f47a1452651 --- /dev/null +++ b/packages/admin-next/admin-bundler/src/index.ts @@ -0,0 +1,3 @@ +export { build } from "./api/build.js" +export { bundle } from "./api/bundle.js" +export { dev } from "./api/dev.js" diff --git a/packages/admin-next/admin-bundler/tsconfig.json b/packages/admin-next/admin-bundler/tsconfig.json new file mode 100644 index 0000000000000..98f1c02d5c952 --- /dev/null +++ b/packages/admin-next/admin-bundler/tsconfig.json @@ -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"] +} diff --git a/packages/admin-next/admin-bundler/tsup.config.ts b/packages/admin-next/admin-bundler/tsup.config.ts new file mode 100644 index 0000000000000..b94cf8b9bc10e --- /dev/null +++ b/packages/admin-next/admin-bundler/tsup.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["./src/index.ts", "./src/cli/index.ts"], + format: ["esm"], + dts: true, +}) diff --git a/packages/admin-next/admin-shared/README.md b/packages/admin-next/admin-shared/README.md new file mode 100644 index 0000000000000..940368442d02f --- /dev/null +++ b/packages/admin-next/admin-shared/README.md @@ -0,0 +1 @@ +# shared diff --git a/packages/admin-next/admin-shared/package.json b/packages/admin-next/admin-shared/package.json new file mode 100644 index 0000000000000..81b227e8ae3bb --- /dev/null +++ b/packages/admin-next/admin-shared/package.json @@ -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" +} diff --git a/packages/admin-next/admin-shared/src/constants.ts b/packages/admin-next/admin-shared/src/constants.ts new file mode 100644 index 0000000000000..e934069d67278 --- /dev/null +++ b/packages/admin-next/admin-shared/src/constants.ts @@ -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 diff --git a/packages/admin-next/admin-shared/src/index.ts b/packages/admin-next/admin-shared/src/index.ts new file mode 100644 index 0000000000000..6c07e3e75f6dc --- /dev/null +++ b/packages/admin-next/admin-shared/src/index.ts @@ -0,0 +1,2 @@ +export * from "./constants" +export * from "./types" diff --git a/packages/admin-next/admin-shared/src/types.ts b/packages/admin-next/admin-shared/src/types.ts new file mode 100644 index 0000000000000..64c703034f636 --- /dev/null +++ b/packages/admin-next/admin-shared/src/types.ts @@ -0,0 +1,3 @@ +import { injectionZones } from "./constants" + +export type InjectionZone = (typeof injectionZones)[number] diff --git a/packages/admin-next/admin-shared/tsconfig.json b/packages/admin-next/admin-shared/tsconfig.json new file mode 100644 index 0000000000000..ae1fe96f1391a --- /dev/null +++ b/packages/admin-next/admin-shared/tsconfig.json @@ -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"] +} diff --git a/packages/admin-next/dashboard/.gitignore b/packages/admin-next/dashboard/.gitignore new file mode 100644 index 0000000000000..fc5ae9f0ccc25 --- /dev/null +++ b/packages/admin-next/dashboard/.gitignore @@ -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 diff --git a/packages/admin-next/dashboard/README.md b/packages/admin-next/dashboard/README.md new file mode 100644 index 0000000000000..0d6babeddbdbc --- /dev/null +++ b/packages/admin-next/dashboard/README.md @@ -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 diff --git a/packages/admin-next/dashboard/index.html b/packages/admin-next/dashboard/index.html new file mode 100644 index 0000000000000..d1812618f33f6 --- /dev/null +++ b/packages/admin-next/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Medusa Admin + + +
+ + + diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json new file mode 100644 index 0000000000000..39e57d182ac54 --- /dev/null +++ b/packages/admin-next/dashboard/package.json @@ -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" +} diff --git a/packages/admin-next/dashboard/postcss.config.cjs b/packages/admin-next/dashboard/postcss.config.cjs new file mode 100644 index 0000000000000..12a703d900da8 --- /dev/null +++ b/packages/admin-next/dashboard/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/admin-next/dashboard/public/locales/$schema.json b/packages/admin-next/dashboard/public/locales/$schema.json new file mode 100644 index 0000000000000..1e210b1de2fe0 --- /dev/null +++ b/packages/admin-next/dashboard/public/locales/$schema.json @@ -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" + ] +} diff --git a/packages/admin-next/dashboard/public/locales/en/translation.json b/packages/admin-next/dashboard/public/locales/en/translation.json new file mode 100644 index 0000000000000..6a3b624d8bb4a --- /dev/null +++ b/packages/admin-next/dashboard/public/locales/en/translation.json @@ -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}} of <1>{{y}} 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." + }, + "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" + } +} diff --git a/packages/admin-next/dashboard/public/vite.svg b/packages/admin-next/dashboard/public/vite.svg new file mode 100644 index 0000000000000..e7b8dfb1b2a60 --- /dev/null +++ b/packages/admin-next/dashboard/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/admin-next/dashboard/src/app.tsx b/packages/admin-next/dashboard/src/app.tsx new file mode 100644 index 0000000000000..9170f0ff08855 --- /dev/null +++ b/packages/admin-next/dashboard/src/app.tsx @@ -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 ( + + + + + + + + + ) +} + +export default App diff --git a/packages/admin-next/dashboard/src/assets/fonts/Inter-Medium.ttf b/packages/admin-next/dashboard/src/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000000000..a01f3777a6fc2 Binary files /dev/null and b/packages/admin-next/dashboard/src/assets/fonts/Inter-Medium.ttf differ diff --git a/packages/admin-next/dashboard/src/assets/fonts/Inter-Regular.ttf b/packages/admin-next/dashboard/src/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000000..5e4851f0ab7e0 Binary files /dev/null and b/packages/admin-next/dashboard/src/assets/fonts/Inter-Regular.ttf differ diff --git a/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Medium.ttf b/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Medium.ttf new file mode 100644 index 0000000000000..f6c149a203537 Binary files /dev/null and b/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Medium.ttf differ diff --git a/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Regular.ttf b/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000..6df2b25360309 Binary files /dev/null and b/packages/admin-next/dashboard/src/assets/fonts/RobotoMono-Regular.ttf differ diff --git a/packages/admin-next/dashboard/src/components/authentication/require-auth/index.ts b/packages/admin-next/dashboard/src/components/authentication/require-auth/index.ts new file mode 100644 index 0000000000000..cb367cc34e81d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/authentication/require-auth/index.ts @@ -0,0 +1 @@ +export * from "./require-auth"; diff --git a/packages/admin-next/dashboard/src/components/authentication/require-auth/require-auth.tsx b/packages/admin-next/dashboard/src/components/authentication/require-auth/require-auth.tsx new file mode 100644 index 0000000000000..d64ad20b873c2 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/authentication/require-auth/require-auth.tsx @@ -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 ( +
+ +
+ ); + } + + if (!auth.user) { + console.log("redirecting"); + return ; + } + + return children; +}; diff --git a/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx b/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx new file mode 100644 index 0000000000000..49fe276389b5d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/debounced-search/debounced-search.tsx @@ -0,0 +1,45 @@ +import { Input } from "@medusajs/ui" +import { ComponentProps, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +type DebouncedSearchProps = Omit< + ComponentProps, + "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(initialValue) + const { t } = useTranslation() + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange?.(value) + }, debounce) + + return () => clearTimeout(timeout) + }, [value]) + + return ( + setValue(e.target.value)} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts b/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts new file mode 100644 index 0000000000000..0bcdec8cc5ec3 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/debounced-search/index.ts @@ -0,0 +1 @@ +export * from "./debounced-search" diff --git a/packages/admin-next/dashboard/src/components/common/form/form.tsx b/packages/admin-next/dashboard/src/components/common/form/form.tsx new file mode 100644 index 0000000000000..b4e385c431a0d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/form/form.tsx @@ -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 = FieldPath +> = { + name: TName +} + +const FormFieldContext = createContext( + {} as FormFieldContextValue +) + +const Field = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = createContext( + {} 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>( + ({ className, ...props }, ref) => { + const id = useId() + + return ( + +
+ + ) + } +) +Item.displayName = "Form.Item" + +const Label = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + optional?: boolean + } +>(({ className, optional = false, ...props }, ref) => { + const { formItemId } = useFormField() + const { t } = useTranslation() + + return ( +
+ + {optional && ( + + ({t("fields.optional")}) + + )} +
+ ) +}) +Label.displayName = "Form.Label" + +const Control = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formErrorMessageId } = + useFormField() + + return ( + + ) +}) +Control.displayName = "Form.Control" + +const Hint = forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + + ) +}) +Hint.displayName = "Form.Hint" + +const ErrorMessage = forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error, formErrorMessageId } = useFormField() + const msg = error ? String(error?.message) : children + + if (!msg) { + return null + } + + return ( + + {msg} + + ) +}) +ErrorMessage.displayName = "Form.ErrorMessage" + +const Form = Object.assign(Provider, { + Item, + Label, + Control, + Hint, + ErrorMessage, + Field, +}) + +export { Form } diff --git a/packages/admin-next/dashboard/src/components/common/form/index.ts b/packages/admin-next/dashboard/src/components/common/form/index.ts new file mode 100644 index 0000000000000..c4a75a7a6924a --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/form/index.ts @@ -0,0 +1 @@ +export * from "./form"; diff --git a/packages/admin-next/dashboard/src/components/common/json-view/index.ts b/packages/admin-next/dashboard/src/components/common/json-view/index.ts new file mode 100644 index 0000000000000..eed209d97dd5f --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/json-view/index.ts @@ -0,0 +1 @@ +export * from "./json-view"; diff --git a/packages/admin-next/dashboard/src/components/common/json-view/json-view.tsx b/packages/admin-next/dashboard/src/components/common/json-view/json-view.tsx new file mode 100644 index 0000000000000..ee8055b46dada --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/json-view/json-view.tsx @@ -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 ( + +
+ JSON + {numberOfKeys} keys +
+ + + + + + + +
+
+ JSON + {numberOfKeys} keys +
+
+ esc + + + + + +
+
+ + Loading...
}> + + { + if (copied) { + return ( + + ) + } + return ( + + ) + }} + /> + " "} /> + ( + null + )} + /> + { + return ( + + {Object.keys(value as object).length} items + + ) + }} + /> + + + + + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts b/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts new file mode 100644 index 0000000000000..8e79962ee024c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/product-table-cells/index.ts @@ -0,0 +1 @@ +export * from "./product-table-cells" diff --git a/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx b/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx new file mode 100644 index 0000000000000..63ca516c8e3ce --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/product-table-cells/product-table-cells.tsx @@ -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 ( + + - + + ) + } + + const inventory = variants.reduce((acc, v) => acc + v.inventory_quantity, 0) + + return ( + + {t("products.inStockVariants", { + count: variants.length, + inventory: inventory, + })} + + ) +} + +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 ( + + {t(`products.productStatus.${status}`)} + + ) +} + +export const ProductAvailabilityCell = ({ + salesChannels, +}: { + salesChannels: SalesChannel[] | null +}) => { + const { t } = useTranslation() + + if (!salesChannels || salesChannels.length === 0) { + return ( + + - + + ) + } + + if (salesChannels.length < 3) { + return ( + + {salesChannels.map((sc) => sc.name).join(", ")} + + ) + } + + return ( +
+ + + {salesChannels + .slice(0, 2) + .map((sc) => sc.name) + .join(", ")} + {" "} + + {t("general.plusCountMore", { + count: salesChannels.length - 2, + })} + + +
+ ) +} + +export const ProductTitleCell = ({ product }: { product: Product }) => { + const thumbnail = product.thumbnail + const title = product.title + + return ( +
+ + + {title} + +
+ ) +} + +export const ProductCollectionCell = ({ + collection, +}: { + collection: ProductCollection | null +}) => { + if (!collection) { + return ( + + - + + ) + } + + return ( + + {collection.title} + + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/thumbnail/index.ts b/packages/admin-next/dashboard/src/components/common/thumbnail/index.ts new file mode 100644 index 0000000000000..2ce6a8611ccec --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/thumbnail/index.ts @@ -0,0 +1 @@ +export * from "./thumbnail"; diff --git a/packages/admin-next/dashboard/src/components/common/thumbnail/thumbnail.tsx b/packages/admin-next/dashboard/src/components/common/thumbnail/thumbnail.tsx new file mode 100644 index 0000000000000..f63583de3afef --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/thumbnail/thumbnail.tsx @@ -0,0 +1,22 @@ +import { Photo } from "@medusajs/icons"; + +type ThumbnailProps = { + src?: string | null; + alt?: string; +}; + +export const Thumbnail = ({ src, alt }: ThumbnailProps) => { + return ( +
+ {src ? ( + {alt} + ) : ( + + )} +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx b/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx new file mode 100644 index 0000000000000..6bc6d1719c197 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/error/error-boundary/error-boundary.tsx @@ -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 + } + + if (error.response?.status === 401) { + return + } + + // TODO: Catch other server errors + } + + // TODO: Actual catch-all error page + return
Dang!
+} + +const isAxiosError = (error: any): error is AxiosError => { + return error.isAxiosError +} diff --git a/packages/admin-next/dashboard/src/components/error/error-boundary/index.ts b/packages/admin-next/dashboard/src/components/error/error-boundary/index.ts new file mode 100644 index 0000000000000..05bbdd4b57f12 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/error/error-boundary/index.ts @@ -0,0 +1 @@ +export { ErrorBoundary } from "./error-boundary" diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/app-layout.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/app-layout.tsx new file mode 100644 index 0000000000000..0946a6a9c34a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/app-layout.tsx @@ -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 ( +
+ +
+ {isSettings && } +
+ + + + +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/breadcrumbs.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/breadcrumbs.tsx new file mode 100644 index 0000000000000..3ccb7ce18e8e4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/breadcrumbs.tsx @@ -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 ( +
    + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + + return ( +
  1. + {!isLast ? ( + {crumb.label} + ) : ( + {crumb.label} + )} + {!isLast && } +
  2. + ); + })} +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/gutter.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/gutter.tsx new file mode 100644 index 0000000000000..00b75f447b546 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/gutter.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from "react"; + +export const Gutter = ({ children }: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/index.ts b/packages/admin-next/dashboard/src/components/layout/app-layout/index.ts new file mode 100644 index 0000000000000..07c56e200ed05 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/index.ts @@ -0,0 +1 @@ +export * from "./app-layout"; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/loader.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/loader.tsx new file mode 100644 index 0000000000000..917b5b80aae65 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/loader.tsx @@ -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)) + ); + }; +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/main-nav.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/main-nav.tsx new file mode 100644 index 0000000000000..9332308ea76d3 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/main-nav.tsx @@ -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 ( + + + + + ) +} + +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 ( +
+ +
+ + + + + + +
+ + + +
+
+
+ +
+ + +
+
+ + + +
+
+
+
+
+ + +
+
+ ) +} + +const DesktopNav = () => { + return ( + + ) +} + +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 ( +
+ + +
+
+
+
+ {store.name[0].toUpperCase()} +
+
+ + {store.name} + +
+
+ +
+
+
+ + + + Store Settings + + + + + + Documentation + + + + + + Changelog + + + + + + + Theme + + + + { + e.preventDefault() + setTheme("light") + }} + > + Light + + { + e.preventDefault() + setTheme("dark") + }} + > + Dark + + + + + + + + Logout + ⌥⇧Q + + +
+
+ ) +} + +const useCoreRoutes = (): Omit[] => { + const { t } = useTranslation() + + return [ + { + icon: , + label: t("orders.domain"), + to: "/orders", + items: [ + { + label: t("draftOrders.domain"), + to: "/draft-orders", + }, + ], + }, + { + icon: , + 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: , + label: t("customers.domain"), + to: "/customers", + items: [ + { + label: t("customerGroups.domain"), + to: "/customer-groups", + }, + ], + }, + { + icon: , + label: t("discounts.domain"), + to: "/discounts", + }, + { + icon: , + label: t("pricing.domain"), + to: "/pricing", + }, + ] +} + +const CoreRouteSection = () => { + const coreRoutes = useCoreRoutes() + + return ( + + ) +} + +const ExtensionRouteSection = () => { + if (!extensions.links || extensions.links.length === 0) { + return null + } + + return ( +
+ +
+ +
+ + + +
+ +
+ {extensions.links.map((link) => { + return ( + : } + type="extension" + /> + ) + })} +
+
+
+
+
+ ) +} + +const SettingsSection = () => { + return ( +
+ } label="Settings" to="/settings" /> +
+ ) +} + +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 ( +
+ + +
+ {(user.first_name || user.last_name) && ( + {`${user.first_name && `${user.first_name} `}${ + user.last_name + }`} + )} + + {user.email} + +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/nav-item.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/nav-item.tsx new file mode 100644 index 0000000000000..23d6f9946ea09 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/nav-item.tsx @@ -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 ( +
+ 0, + } + )} + > + + + {label} + + + {items && items.length > 0 && ( + + + + + {label} + + + + +
+
+
+ + {label} + + + {items.map((item) => { + return ( + +
+
+
+ + {item.label} + + + ); + })} + + + )} +
+ ); +}; + +const Icon = ({ icon, type }: { icon?: React.ReactNode; type: ItemType }) => { + if (!icon) { + return null; + } + + return type === "extension" ? ( +
+
{icon}
+
+ ) : ( + icon + ); +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/notifications.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/notifications.tsx new file mode 100644 index 0000000000000..6af0a698a3f0a --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/notifications.tsx @@ -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 ( + + + + + + + + + Notifications + + Notifications will go here + + + ); +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/search-toggle.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/search-toggle.tsx new file mode 100644 index 0000000000000..949cca5ce13c2 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/search-toggle.tsx @@ -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 ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/settings-nav.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/settings-nav.tsx new file mode 100644 index 0000000000000..acb25a8aa93b6 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/settings-nav.tsx @@ -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 ( +
+
+
+ + + {t("general.settings")} + +
+
+ +
+ +
+ + + +
+ + + +
+ +
+ + + +
+ + + +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/spacer.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/spacer.tsx new file mode 100644 index 0000000000000..4e00210d2f17c --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/spacer.tsx @@ -0,0 +1,7 @@ +export const Spacer = () => { + return ( +
+
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/components/layout/app-layout/topbar.tsx b/packages/admin-next/dashboard/src/components/layout/app-layout/topbar.tsx new file mode 100644 index 0000000000000..cd2bdc40db765 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/app-layout/topbar.tsx @@ -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 ( +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/layout/public-layout/index.ts b/packages/admin-next/dashboard/src/components/layout/public-layout/index.ts new file mode 100644 index 0000000000000..b207d31a78b13 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/public-layout/index.ts @@ -0,0 +1 @@ +export * from "./public-layout"; diff --git a/packages/admin-next/dashboard/src/components/layout/public-layout/public-layout.tsx b/packages/admin-next/dashboard/src/components/layout/public-layout/public-layout.tsx new file mode 100644 index 0000000000000..042fe32ac198d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/layout/public-layout/public-layout.tsx @@ -0,0 +1,14 @@ +import { Outlet } from "react-router-dom"; + +export const PublicLayout = () => { + return ( +
+
+
+
+ +
+
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/components/search/index.ts b/packages/admin-next/dashboard/src/components/search/index.ts new file mode 100644 index 0000000000000..c368ec9117d09 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/search/index.ts @@ -0,0 +1 @@ +export { Search } from "./search" diff --git a/packages/admin-next/dashboard/src/components/search/search.tsx b/packages/admin-next/dashboard/src/components/search/search.tsx new file mode 100644 index 0000000000000..5bee68d10a922 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/search/search.tsx @@ -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 ( + + + + No results found. + {links.map((group) => { + return ( + + {group.items.map((item) => { + return ( + + {item.label} + + ) + })} + + ) + })} + + + ) +} + +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, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = Command.displayName + +interface CommandDialogProps extends Dialog.DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + + + {children} + +
+
+
+
+ ) +} + +const CommandInput = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = Command.Input.displayName + +const CommandList = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = Command.List.displayName + +const CommandEmpty = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = Command.Empty.displayName + +const CommandGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = Command.Group.displayName + +const CommandSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = Command.Separator.displayName + +const CommandItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = Command.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" diff --git a/packages/admin-next/dashboard/src/i18n/config.ts b/packages/admin-next/dashboard/src/i18n/config.ts new file mode 100644 index 0000000000000..c346a4b8aca68 --- /dev/null +++ b/packages/admin-next/dashboard/src/i18n/config.ts @@ -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({ + 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 diff --git a/packages/admin-next/dashboard/src/i18n/types.ts b/packages/admin-next/dashboard/src/i18n/types.ts new file mode 100644 index 0000000000000..6dd753ee66eb8 --- /dev/null +++ b/packages/admin-next/dashboard/src/i18n/types.ts @@ -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 +} diff --git a/packages/admin-next/dashboard/src/i18next.d.ts b/packages/admin-next/dashboard/src/i18next.d.ts new file mode 100644 index 0000000000000..bd7cf8079ab10 --- /dev/null +++ b/packages/admin-next/dashboard/src/i18next.d.ts @@ -0,0 +1,7 @@ +import { Resources } from "./i18n/types"; + +declare module "i18next" { + interface CustomTypeOptions { + resources: Resources; + } +} diff --git a/packages/admin-next/dashboard/src/index.css b/packages/admin-next/dashboard/src/index.css new file mode 100644 index 0000000000000..b8cac0738d00b --- /dev/null +++ b/packages/admin-next/dashboard/src/index.css @@ -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; + } +} diff --git a/packages/admin-next/dashboard/src/lib/medusa.ts b/packages/admin-next/dashboard/src/lib/medusa.ts new file mode 100644 index 0000000000000..5b3a4f4abe96d --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/medusa.ts @@ -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, +}) diff --git a/packages/admin-next/dashboard/src/main.tsx b/packages/admin-next/dashboard/src/main.tsx new file mode 100644 index 0000000000000..1a7a8db9a53c0 --- /dev/null +++ b/packages/admin-next/dashboard/src/main.tsx @@ -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( + + + +) diff --git a/packages/admin-next/dashboard/src/module.d.ts b/packages/admin-next/dashboard/src/module.d.ts new file mode 100644 index 0000000000000..f4e02f1344504 --- /dev/null +++ b/packages/admin-next/dashboard/src/module.d.ts @@ -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, + } +} diff --git a/packages/admin-next/dashboard/src/providers/auth-provider/auth-context.tsx b/packages/admin-next/dashboard/src/providers/auth-provider/auth-context.tsx new file mode 100644 index 0000000000000..4068ea01d8c6d --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/auth-provider/auth-context.tsx @@ -0,0 +1,10 @@ +import { AdminAuthRes, User } from "@medusajs/medusa" +import { createContext } from "react" + +type AuthContextValue = { + login: (email: string, password: string) => Promise + user: Omit | null + isLoading: boolean +} + +export const AuthContext = createContext(null) diff --git a/packages/admin-next/dashboard/src/providers/auth-provider/auth-provider.tsx b/packages/admin-next/dashboard/src/providers/auth-provider/auth-provider.tsx new file mode 100644 index 0000000000000..ddb2589cd46db --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/auth-provider/auth-provider.tsx @@ -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 ( + + {children} + + ) +} diff --git a/packages/admin-next/dashboard/src/providers/auth-provider/index.ts b/packages/admin-next/dashboard/src/providers/auth-provider/index.ts new file mode 100644 index 0000000000000..c2b66aa1c09de --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/auth-provider/index.ts @@ -0,0 +1,2 @@ +export * from "./auth-provider"; +export * from "./use-auth"; diff --git a/packages/admin-next/dashboard/src/providers/auth-provider/use-auth.tsx b/packages/admin-next/dashboard/src/providers/auth-provider/use-auth.tsx new file mode 100644 index 0000000000000..8bef3683caaf5 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/auth-provider/use-auth.tsx @@ -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; +}; diff --git a/packages/admin-next/dashboard/src/providers/feature-provider/feature-context.tsx b/packages/admin-next/dashboard/src/providers/feature-provider/feature-context.tsx new file mode 100644 index 0000000000000..6174089677a5a --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/feature-provider/feature-context.tsx @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import { Feature } from "./types"; + +type FeatureContextValue = { + isFeatureEnabled: (feature: Feature) => boolean; +}; + +export const FeatureContext = createContext(null); diff --git a/packages/admin-next/dashboard/src/providers/feature-provider/feature-provider.tsx b/packages/admin-next/dashboard/src/providers/feature-provider/feature-provider.tsx new file mode 100644 index 0000000000000..106f9c60e951a --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/feature-provider/feature-provider.tsx @@ -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([]); + + 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 ( + + {children} + + ); +}; diff --git a/packages/admin-next/dashboard/src/providers/feature-provider/index.ts b/packages/admin-next/dashboard/src/providers/feature-provider/index.ts new file mode 100644 index 0000000000000..a78a41ce93f96 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/feature-provider/index.ts @@ -0,0 +1,2 @@ +export { FeatureProvider } from "./feature-provider"; +export { useFeature } from "./use-feature"; diff --git a/packages/admin-next/dashboard/src/providers/feature-provider/types.ts b/packages/admin-next/dashboard/src/providers/feature-provider/types.ts new file mode 100644 index 0000000000000..cc639bb4f1507 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/feature-provider/types.ts @@ -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; diff --git a/packages/admin-next/dashboard/src/providers/feature-provider/use-feature.tsx b/packages/admin-next/dashboard/src/providers/feature-provider/use-feature.tsx new file mode 100644 index 0000000000000..d49a8548f3cd9 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/feature-provider/use-feature.tsx @@ -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; +}; diff --git a/packages/admin-next/dashboard/src/providers/router-provider/index.ts b/packages/admin-next/dashboard/src/providers/router-provider/index.ts new file mode 100644 index 0000000000000..c259add166444 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/router-provider/index.ts @@ -0,0 +1 @@ +export * from "./router-provider"; diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx new file mode 100644 index 0000000000000..ae5b05bfb891b --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -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: , + children: [ + { + path: "/login", + lazy: () => import("../../routes/login"), + }, + ], + }, + { + element: ( + + + + + + ), + errorElement: , + 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 +} diff --git a/packages/admin-next/dashboard/src/providers/search-provider/index.ts b/packages/admin-next/dashboard/src/providers/search-provider/index.ts new file mode 100644 index 0000000000000..990ae4daeada7 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/search-provider/index.ts @@ -0,0 +1,2 @@ +export { SearchProvider } from "./search-provider" +export { useSearch } from "./use-search" diff --git a/packages/admin-next/dashboard/src/providers/search-provider/search-context.tsx b/packages/admin-next/dashboard/src/providers/search-provider/search-context.tsx new file mode 100644 index 0000000000000..d8ea6ddc84341 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/search-provider/search-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react" + +type SearchContextValue = { + open: boolean + onOpenChange: (open: boolean) => void + toggleSearch: () => void +} + +export const SearchContext = createContext(null) diff --git a/packages/admin-next/dashboard/src/providers/search-provider/search-provider.tsx b/packages/admin-next/dashboard/src/providers/search-provider/search-provider.tsx new file mode 100644 index 0000000000000..eb42c28c22eb3 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/search-provider/search-provider.tsx @@ -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 ( + + {children} + + + ) +} diff --git a/packages/admin-next/dashboard/src/providers/search-provider/use-search.tsx b/packages/admin-next/dashboard/src/providers/search-provider/use-search.tsx new file mode 100644 index 0000000000000..88b6f2863fcd9 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/search-provider/use-search.tsx @@ -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 +} diff --git a/packages/admin-next/dashboard/src/providers/theme-provider/index.ts b/packages/admin-next/dashboard/src/providers/theme-provider/index.ts new file mode 100644 index 0000000000000..d12c36092f5f4 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/theme-provider/index.ts @@ -0,0 +1,3 @@ +export type { Theme } from "./theme-context"; +export * from "./theme-provider"; +export * from "./use-theme"; diff --git a/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx b/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx new file mode 100644 index 0000000000000..bcb55c682333e --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/theme-provider/theme-context.tsx @@ -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(null); diff --git a/packages/admin-next/dashboard/src/providers/theme-provider/theme-provider.tsx b/packages/admin-next/dashboard/src/providers/theme-provider/theme-provider.tsx new file mode 100644 index 0000000000000..0bb741620db2e --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/theme-provider/theme-provider.tsx @@ -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( + (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 ( + + {children} + + ); +}; diff --git a/packages/admin-next/dashboard/src/providers/theme-provider/use-theme.tsx b/packages/admin-next/dashboard/src/providers/theme-provider/use-theme.tsx new file mode 100644 index 0000000000000..e272a578b4173 --- /dev/null +++ b/packages/admin-next/dashboard/src/providers/theme-provider/use-theme.tsx @@ -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; +}; diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management.tsx new file mode 100644 index 0000000000000..d896d2578a62d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management.tsx @@ -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
Loading
+ } + + // 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 ( +
+ {(err as Error & { status: number })?.status === 404 ? ( +
Not found
+ ) : ( +
Something went wrong!
+ )} +
+ ) + } + + const hasData = publishable_api_keys.length !== 0 + + return ( +
+ +
+ {t("apiKeyManagement.domain")} +
+
+ {hasData ? ( + + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ ) : ( +
+
+
+ + + {t("general.noRecordsFound")} + + + {t("apiKeyManagement.createAPublishableApiKey")} + +
+ +
+
+ )} +
+
+
+ +
+ ) +} + +const columnHelper = createColumnHelper() + +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() + +const useSalesChannelColumns = () => { + const { t } = useTranslation() + + const columns = useMemo( + () => [ + salesChannelColumnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + 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({ + 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 ( + +
+ + +
+ + +
+
+ +
+
+ Create API Key + + Create and manage API keys. API keys are used to limit the + scope of requests to specific sales channels. + +
+
+
+ ( + + {t("fields.title")} + + + + + + )} + /> +
+
+ + + +
+ Sales Channels +
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+
+
+
+
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/index.ts new file mode 100644 index 0000000000000..9ddecac337c28 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/index.ts @@ -0,0 +1 @@ +export { ApiKeyManagement as Component } from "./api-key-management" diff --git a/packages/admin-next/dashboard/src/routes/categories/details/details.tsx b/packages/admin-next/dashboard/src/routes/categories/details/details.tsx new file mode 100644 index 0000000000000..f2b6b1cf9cdcc --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/categories/details/details.tsx @@ -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 ( +
+ {before.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} + + Category + + {after.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/categories/details/index.ts b/packages/admin-next/dashboard/src/routes/categories/details/index.ts new file mode 100644 index 0000000000000..b3fb063949c4d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/categories/details/index.ts @@ -0,0 +1 @@ +export { CategoryDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/categories/list/index.ts b/packages/admin-next/dashboard/src/routes/categories/list/index.ts new file mode 100644 index 0000000000000..3bb3f12d0021f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/categories/list/index.ts @@ -0,0 +1 @@ +export { CategoriesList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/categories/list/list.tsx b/packages/admin-next/dashboard/src/routes/categories/list/list.tsx new file mode 100644 index 0000000000000..9048fd81619d4 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/categories/list/list.tsx @@ -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 ( +
+ {before.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} + + Categories + + {after.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/collections/details/details.tsx b/packages/admin-next/dashboard/src/routes/collections/details/details.tsx new file mode 100644 index 0000000000000..eaa4c0692f717 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/collections/details/details.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CollectionDetails = () => { + return ( +
+ + Collection + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/collections/details/index.ts b/packages/admin-next/dashboard/src/routes/collections/details/index.ts new file mode 100644 index 0000000000000..3b15dda2e499f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/collections/details/index.ts @@ -0,0 +1 @@ +export { CollectionDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/collections/list/index.ts b/packages/admin-next/dashboard/src/routes/collections/list/index.ts new file mode 100644 index 0000000000000..aed84bc76a712 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/collections/list/index.ts @@ -0,0 +1 @@ +export { CollectionsList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/collections/list/list.tsx b/packages/admin-next/dashboard/src/routes/collections/list/list.tsx new file mode 100644 index 0000000000000..ec9bda189e159 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/collections/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CollectionsList = () => { + return ( +
+ + Collections + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/edit-currencies-details-drawer.tsx b/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/edit-currencies-details-drawer.tsx new file mode 100644 index 0000000000000..3a2fc31169181 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/edit-currencies-details-drawer.tsx @@ -0,0 +1,159 @@ +import { Store } from "@medusajs/medusa" +import { Button, Drawer, Heading, Select } from "@medusajs/ui" +import { useAdminUpdateStore } from "medusa-react" +import { 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" + +const EditCurrenciesDetailsSchema = zod.object({ + default_currency_code: zod.string(), +}) + +type EditCurrenciesDetailsDrawerProps = { + store: Store +} + +export const EditCurrenciesDetailsDrawer = ({ + store, +}: EditCurrenciesDetailsDrawerProps) => { + const [open, setOpen] = useState(false) + const [selectOpen, setSelectOpen] = useState(false) + + const { t } = useTranslation() + + const { mutateAsync } = useAdminUpdateStore() + + const form = useForm>({ + defaultValues: { + default_currency_code: store.default_currency_code, + }, + }) + + const sortedCurrencies = store.currencies.sort((a, b) => { + if (a.code === store.default_currency_code) { + return -1 + } + + if (b.code === store.default_currency_code) { + return 1 + } + + return a.code.localeCompare(b.code) + }) + + const onOpenChange = (open: boolean) => { + if (!open) { + form.reset() + + /** + * We need to close the select when the drawer closes. + * Otherwise it may lead to `pointer-events: none` being applied to the body. + */ + setSelectOpen(false) + } + setOpen(open) + } + + const onSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + default_currency_code: values.default_currency_code, + }, + { + onSuccess: ({ store }) => { + form.reset({ + default_currency_code: store.default_currency_code, + }) + + onOpenChange(false) + }, + onError: (err) => { + console.log(err) + }, + } + ) + }) + + return ( + + + + + + + {t("currencies.editCurrencyDetails")} + + +
+ + { + return ( + + {t("currencies.defaultCurrency")} +
+ + + + +
+ + {t("currencies.defaultCurrencyHint")} + +
+ ) + }} + /> + + +
+ + + + + + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/index.ts b/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/index.ts new file mode 100644 index 0000000000000..8a247de79e4c3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/currencies/components/edit-currencies-details-drawer/index.ts @@ -0,0 +1 @@ +export * from "./edit-currencies-details-drawer" diff --git a/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/currencies-details.tsx b/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/currencies-details.tsx new file mode 100644 index 0000000000000..c2550a459a20f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/currencies-details.tsx @@ -0,0 +1,488 @@ +import { + BuildingTax, + CurrencyDollar, + EllipsisHorizontal, +} from "@medusajs/icons" +import { Currency, Store } from "@medusajs/medusa" +import { + Badge, + Button, + Checkbox, + CommandBar, + Container, + DropdownMenu, + FocusModal, + Heading, + IconButton, + Table, + Text, + Tooltip, + clx, + usePrompt, +} from "@medusajs/ui" +import { + PaginationState, + RowSelectionState, + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + useAdminCurrencies, + useAdminStore, + useAdminUpdateStore, +} from "medusa-react" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { EditCurrenciesDetailsDrawer } from "../../components/edit-currencies-details-drawer" + +export const CurrenciesDetails = () => { + const { t } = useTranslation() + + const { store, isLoading } = useAdminStore() + + if (isLoading || !store) { + return
Loading...
+ } + + return ( +
+ +
+
+ {t("currencies.domain")} + + {t("currencies.manageTheCurrencies")} + +
+ +
+
+ + {t("currencies.defaultCurrency")} + +
+ + {store.default_currency_code} + + + {store.default_currency.name} + +
+
+
+ +
+ ) +} + +type StoreCurrenciesSectionProps = { + store: Store +} + +const PAGE_SIZE = 20 + +const StoreCurrencySection = ({ store }: StoreCurrenciesSectionProps) => { + const [addModalOpen, setAddModalOpen] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + const { mutateAsync } = useAdminUpdateStore() + const prompt = usePrompt() + const { t } = useTranslation() + const pageCount = Math.ceil(store.currencies.length / PAGE_SIZE) + const columns = useStoreCurrencyColumns() + + const table = useReactTable({ + data: store.currencies, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onRowSelectionChange: setRowSelection, + pageCount: pageCount, + state: { + rowSelection, + }, + }) + + const onDeleteCurrencies = async () => { + const ids = Object.keys(rowSelection) + + const result = await prompt({ + title: t("general.areYouSure"), + description: t("currencies.removeCurrenciesWarning", { + count: ids.length, + }), + confirmText: t("general.remove"), + cancelText: t("general.cancel"), + }) + + if (!result) { + return + } + + await mutateAsync({ + currencies: store.currencies + .filter((c) => !ids.includes(c.code)) + .map((c) => c.code), + }) + } + + return ( + +
+ Store Currencies + + + + + + + + setAddModalOpen(!addModalOpen)} + > + + Add Currencies + + + + Tax Preferences + + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+ + + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + + + +
+ +
+ ) +} + +const storeCurrencyColumnHelper = createColumnHelper() + +const useStoreCurrencyColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + storeCurrencyColumnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + storeCurrencyColumnHelper.accessor("code", { + header: t("fields.code"), + cell: ({ getValue }) => getValue().toUpperCase(), + }), + storeCurrencyColumnHelper.accessor("name", { + header: t("fields.name"), + cell: ({ getValue }) => getValue(), + }), + storeCurrencyColumnHelper.accessor("includes_tax", { + header: "Tax Inclusive Prices", + cell: ({ getValue }) => { + return getValue() ? t("general.enabled") : t("general.disabled") + }, + }), + ], + [t] + ) +} + +const CURRENCIES_PAGE_SIZE = 50 + +const AddCurrenciesModal = ({ + store, + open, + onOpenChange, +}: { + store: Store + open: boolean + onOpenChange: (open: boolean) => void +}) => { + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: CURRENCIES_PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const [rowSelection, setRowSelection] = useState({}) + + const { currencies, count, isLoading } = useAdminCurrencies({ + limit: CURRENCIES_PAGE_SIZE, + offset: pageIndex * CURRENCIES_PAGE_SIZE, + }) + + const columns = useCurrencyColumns() + + const table = useReactTable({ + data: currencies ?? [], + columns, + pageCount: Math.ceil((count ?? 0) / CURRENCIES_PAGE_SIZE), + state: { + pagination, + rowSelection, + }, + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + meta: { + currencyCodes: store.currencies?.map((c) => c.code) ?? [], + }, + }) + + const { t } = useTranslation() + + return ( + + + +
+ + +
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + c.code) + ?.includes(row.original.code), + }, + { + "bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover": + row.getIsSelected(), + } + )} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+
+ +
+
+
+
+ ) +} + +const currencyColumnHelper = createColumnHelper() + +const useCurrencyColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + currencyColumnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row, table }) => { + const { currencyCodes } = table.options.meta as { + currencyCodes: string[] + } + + const isAdded = currencyCodes.includes(row.original.code) + + const isSelected = row.getIsSelected() || isAdded + + const Component = ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + + if (isAdded) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + currencyColumnHelper.accessor("code", { + header: t("fields.code"), + cell: ({ getValue }) => getValue().toUpperCase(), + }), + currencyColumnHelper.accessor("name", { + header: t("fields.name"), + cell: ({ getValue }) => getValue(), + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/index.ts b/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/index.ts new file mode 100644 index 0000000000000..5069f29b0c7be --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/currencies/views/currencies-details/index.ts @@ -0,0 +1 @@ +export { CurrenciesDetails as Component } from "./currencies-details" diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/details/details.tsx b/packages/admin-next/dashboard/src/routes/customer-groups/details/details.tsx new file mode 100644 index 0000000000000..cc59b9ecdcde8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/details/details.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CustomerGroupDetails = () => { + return ( +
+ + Customers + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/details/index.ts b/packages/admin-next/dashboard/src/routes/customer-groups/details/index.ts new file mode 100644 index 0000000000000..bb5e10a796f37 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/details/index.ts @@ -0,0 +1 @@ +export { CustomerGroupDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/list/index.ts b/packages/admin-next/dashboard/src/routes/customer-groups/list/index.ts new file mode 100644 index 0000000000000..c9929eedb7b7e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/list/index.ts @@ -0,0 +1 @@ +export { CustomerGroupsList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/customer-groups/list/list.tsx b/packages/admin-next/dashboard/src/routes/customer-groups/list/list.tsx new file mode 100644 index 0000000000000..6d5f9e292b63f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customer-groups/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CustomerGroupsList = () => { + return ( +
+ + Customer Groups + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/customers/details/details.tsx b/packages/admin-next/dashboard/src/routes/customers/details/details.tsx new file mode 100644 index 0000000000000..542e709a0bebd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/details/details.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CustomerDetails = () => { + return ( +
+ + Customers + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/customers/details/index.ts b/packages/admin-next/dashboard/src/routes/customers/details/index.ts new file mode 100644 index 0000000000000..884ed5286cb9f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/details/index.ts @@ -0,0 +1 @@ +export { CustomerDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/customers/list/index.ts b/packages/admin-next/dashboard/src/routes/customers/list/index.ts new file mode 100644 index 0000000000000..a4826e8abab9f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/list/index.ts @@ -0,0 +1 @@ +export { CustomersList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/customers/list/list.tsx b/packages/admin-next/dashboard/src/routes/customers/list/list.tsx new file mode 100644 index 0000000000000..7fdc230dd924e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/customers/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const CustomersList = () => { + return ( +
+ + Customers + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/discounts/details/details.tsx b/packages/admin-next/dashboard/src/routes/discounts/details/details.tsx new file mode 100644 index 0000000000000..e93087080eeb9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/discounts/details/details.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const DiscountsDetails = () => { + return ( +
+ + Discounts + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/discounts/details/index.ts b/packages/admin-next/dashboard/src/routes/discounts/details/index.ts new file mode 100644 index 0000000000000..a189be735dcd4 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/discounts/details/index.ts @@ -0,0 +1 @@ +export { DiscountsDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/discounts/list/index.ts b/packages/admin-next/dashboard/src/routes/discounts/list/index.ts new file mode 100644 index 0000000000000..9ede617802d13 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/discounts/list/index.ts @@ -0,0 +1 @@ +export { DiscountsList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/discounts/list/list.tsx b/packages/admin-next/dashboard/src/routes/discounts/list/list.tsx new file mode 100644 index 0000000000000..5be301f0c6f52 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/discounts/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const DiscountsList = () => { + return ( +
+ + Discounts + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/details/details.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/details/details.tsx new file mode 100644 index 0000000000000..b8524e12f6d4e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/details/details.tsx @@ -0,0 +1,3 @@ +export const DraftOrderDetails = () => { + return
Draft Order Details
; +}; diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/details/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/details/index.ts new file mode 100644 index 0000000000000..161c177badda0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/details/index.ts @@ -0,0 +1 @@ +export { DraftOrderDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/list/index.ts b/packages/admin-next/dashboard/src/routes/draft-orders/list/index.ts new file mode 100644 index 0000000000000..faac1801864a3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/list/index.ts @@ -0,0 +1 @@ +export { DraftOrderList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/draft-orders/list/list.tsx b/packages/admin-next/dashboard/src/routes/draft-orders/list/list.tsx new file mode 100644 index 0000000000000..bb62d485b5aae --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/draft-orders/list/list.tsx @@ -0,0 +1,9 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const DraftOrderList = () => { + return ( + + Draft Orders + + ); +}; diff --git a/packages/admin-next/dashboard/src/routes/gift-cards/details/details.tsx b/packages/admin-next/dashboard/src/routes/gift-cards/details/details.tsx new file mode 100644 index 0000000000000..a3fb2af4c76d7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/gift-cards/details/details.tsx @@ -0,0 +1,3 @@ +export const GiftCardDetails = () => { + return
Gift Card Details
; +}; diff --git a/packages/admin-next/dashboard/src/routes/gift-cards/details/index.ts b/packages/admin-next/dashboard/src/routes/gift-cards/details/index.ts new file mode 100644 index 0000000000000..efdfebaf78488 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/gift-cards/details/index.ts @@ -0,0 +1 @@ +export { GiftCardDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/gift-cards/list/index.ts b/packages/admin-next/dashboard/src/routes/gift-cards/list/index.ts new file mode 100644 index 0000000000000..b28d751cab5f8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/gift-cards/list/index.ts @@ -0,0 +1 @@ +export { GiftCardList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/gift-cards/list/list.tsx b/packages/admin-next/dashboard/src/routes/gift-cards/list/list.tsx new file mode 100644 index 0000000000000..bde0081772b36 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/gift-cards/list/list.tsx @@ -0,0 +1,9 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const GiftCardList = () => { + return ( + + Gift Card List + + ); +}; diff --git a/packages/admin-next/dashboard/src/routes/home/home.tsx b/packages/admin-next/dashboard/src/routes/home/home.tsx new file mode 100644 index 0000000000000..39541fa17db3d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/home/home.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +export const Home = () => { + const navigate = useNavigate(); + + // Currently, the home page simply redirects to the orders page + useEffect(() => { + navigate("/orders", { replace: true }); + }, [navigate]); + + return
; +}; diff --git a/packages/admin-next/dashboard/src/routes/home/index.ts b/packages/admin-next/dashboard/src/routes/home/index.ts new file mode 100644 index 0000000000000..ea1e81bb8adb8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/home/index.ts @@ -0,0 +1 @@ +export { Home as Component } from "./home"; diff --git a/packages/admin-next/dashboard/src/routes/inventory/list/index.ts b/packages/admin-next/dashboard/src/routes/inventory/list/index.ts new file mode 100644 index 0000000000000..cc22dde9bcf1d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/inventory/list/index.ts @@ -0,0 +1 @@ +export { InventoryList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/inventory/list/list.tsx b/packages/admin-next/dashboard/src/routes/inventory/list/list.tsx new file mode 100644 index 0000000000000..792b2186be739 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/inventory/list/list.tsx @@ -0,0 +1,9 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const InventoryList = () => { + return ( + + Inventory + + ); +}; diff --git a/packages/admin-next/dashboard/src/routes/locations/list/index.ts b/packages/admin-next/dashboard/src/routes/locations/list/index.ts new file mode 100644 index 0000000000000..4ede307bd7acd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/locations/list/index.ts @@ -0,0 +1 @@ +export { LocationsList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/locations/list/list.tsx b/packages/admin-next/dashboard/src/routes/locations/list/list.tsx new file mode 100644 index 0000000000000..25a6d42694b32 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/locations/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const LocationsList = () => { + return ( +
+ + Locations + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/login/index.ts b/packages/admin-next/dashboard/src/routes/login/index.ts new file mode 100644 index 0000000000000..984b6fdd60d93 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/login/index.ts @@ -0,0 +1 @@ +export { Login as Component } from "./login"; diff --git a/packages/admin-next/dashboard/src/routes/login/login.tsx b/packages/admin-next/dashboard/src/routes/login/login.tsx new file mode 100644 index 0000000000000..e042eccff2d20 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/login/login.tsx @@ -0,0 +1,101 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Button, Heading, Input, Text } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { Link, useLocation, useNavigate } from "react-router-dom" +import * as z from "zod" + +import { Form } from "../../components/common/form" +import { useAuth } from "../../providers/auth-provider" + +const schema = z.object({ + email: z.string().email(), + password: z.string(), +}) + +export const Login = () => { + const navigate = useNavigate() + const location = useLocation() + const { login } = useAuth() + + const from = location.state?.from?.pathname || "/" + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + password: "", + }, + }) + + const onSubmit = form.handleSubmit(async ({ email, password }) => { + await login(email, password) + .then(() => { + navigate(from) + }) + .catch((e) => { + switch (e?.response?.status) { + case 401: + form.setError("password", { + type: "manual", + message: "Invalid email or password", + }) + break + default: + form.setError("password", { + type: "manual", + message: "Something went wrong", + }) + } + }) + }) + + return ( +
+
+ Login + Welcome back. Login to get started! +
+
+ + ( + + Email + + + + + + )} + /> + ( + +
+ Password + + Forgot password? + +
+ + + + +
+ )} + /> + + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/no-match/index.ts b/packages/admin-next/dashboard/src/routes/no-match/index.ts new file mode 100644 index 0000000000000..b934e7afd6b5c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/no-match/index.ts @@ -0,0 +1 @@ +export { NoMatch as Component } from "./no-match"; diff --git a/packages/admin-next/dashboard/src/routes/no-match/no-match.tsx b/packages/admin-next/dashboard/src/routes/no-match/no-match.tsx new file mode 100644 index 0000000000000..e6d1116c61347 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/no-match/no-match.tsx @@ -0,0 +1,3 @@ +export const NoMatch = () => { + return
404 Not Found
; +}; diff --git a/packages/admin-next/dashboard/src/routes/orders/details/details.tsx b/packages/admin-next/dashboard/src/routes/orders/details/details.tsx new file mode 100644 index 0000000000000..24ef1a58301ff --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/details/details.tsx @@ -0,0 +1,3 @@ +export const OrderDetails = () => { + return
Order Details
; +}; diff --git a/packages/admin-next/dashboard/src/routes/orders/details/index.ts b/packages/admin-next/dashboard/src/routes/orders/details/index.ts new file mode 100644 index 0000000000000..0e5b01fb734a0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/details/index.ts @@ -0,0 +1 @@ +export { OrderDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/orders/list/index.ts b/packages/admin-next/dashboard/src/routes/orders/list/index.ts new file mode 100644 index 0000000000000..ad7ea56183413 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/list/index.ts @@ -0,0 +1 @@ +export { OrderList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/orders/list/list.tsx b/packages/admin-next/dashboard/src/routes/orders/list/list.tsx new file mode 100644 index 0000000000000..d5498116ac06c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const OrderList = () => { + return ( +
+ + Orders + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx b/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx new file mode 100644 index 0000000000000..6e417894474df --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const PricingDetails = () => { + return ( +
+ + Pricing + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/pricing/details/index.ts b/packages/admin-next/dashboard/src/routes/pricing/details/index.ts new file mode 100644 index 0000000000000..720fdcf6f816e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/details/index.ts @@ -0,0 +1 @@ +export { PricingDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/pricing/list/index.ts b/packages/admin-next/dashboard/src/routes/pricing/list/index.ts new file mode 100644 index 0000000000000..b2b32b4611953 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/list/index.ts @@ -0,0 +1 @@ +export { PricingList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx b/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx new file mode 100644 index 0000000000000..fe3df51b7e2bd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx @@ -0,0 +1,11 @@ +import { Container, Heading } from "@medusajs/ui"; + +export const PricingList = () => { + return ( +
+ + Pricing + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/index.ts new file mode 100644 index 0000000000000..b61348398df56 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/index.ts @@ -0,0 +1 @@ +export * from "./product-attribute-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/product-attribute-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/product-attribute-section.tsx new file mode 100644 index 0000000000000..92e2bf8912e06 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-attribute-section/product-attribute-section.tsx @@ -0,0 +1,18 @@ +import { Product } from "@medusajs/medusa"; +import { Container, Heading } from "@medusajs/ui"; + +type ProductAttributeSectionProps = { + product: Product; +}; + +export const ProductAttributeSection = ({ + product, +}: ProductAttributeSectionProps) => { + return ( +
+ + Attributes + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-general-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-general-section/index.ts new file mode 100644 index 0000000000000..1f711401bf939 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-general-section/index.ts @@ -0,0 +1 @@ +export * from "./product-general-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-general-section/product-general-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-general-section/product-general-section.tsx new file mode 100644 index 0000000000000..d4b06f84448b0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-general-section/product-general-section.tsx @@ -0,0 +1,109 @@ +import { EllipseGreenSolid, EllipsisHorizontal } from "@medusajs/icons"; +import { Product, ProductTag } from "@medusajs/medusa"; +import { + Badge, + Button, + Container, + Heading, + IconButton, + Text, +} from "@medusajs/ui"; +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +type ProductGeneralSectionProps = { + product: Product; +}; + +export const ProductGeneralSection = ({ + product, +}: ProductGeneralSectionProps) => { + return ( +
+ +
+
+
+ {product.title} +
+ + + + +
+
+
+ {product.description} +
+ +
+ +
+
+
+ ); +}; + +type ProductTagsProps = { + tags?: ProductTag[] | null; +}; + +const ProductTags = ({ tags }: ProductTagsProps) => { + if (!tags || tags.length === 0) { + return null; + } + + return ( +
+ {tags.map((t) => { + return ( + + {t.value} + + ); + })} +
+ ); +}; + +type ProductDetailProps = { + label: string; + value?: ReactNode; +}; + +const ProductDetail = ({ label, value }: ProductDetailProps) => { + return ( +
+ {label} + {value ? value : "-"} +
+ ); +}; + +type ProductDetailsProps = { + product: Product; +}; + +const ProductDetails = ({ product }: ProductDetailsProps) => { + const { t } = useTranslation(); + + return ( +
+ {t("general.details")} +
+ + + + + + +
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-media-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-media-section/index.ts new file mode 100644 index 0000000000000..3ebde5696add3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-media-section/index.ts @@ -0,0 +1 @@ +export * from "./product-media-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-media-section/product-media-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-media-section/product-media-section.tsx new file mode 100644 index 0000000000000..a765327b0455d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-media-section/product-media-section.tsx @@ -0,0 +1,36 @@ +import { Product } from "@medusajs/medusa"; +import { Container, Heading } from "@medusajs/ui"; + +type ProductMedisaSectionProps = { + product: Product; +}; + +export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => { + return ( +
+ +
+ Media +
+ {product.images?.length > 0 && ( +
+ {product.images.map((i) => { + return ( +
+ {`${product.title} +
+ ); + })} +
+ )} +
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-option-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-option-section/index.ts new file mode 100644 index 0000000000000..c705e38c43b0c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-option-section/index.ts @@ -0,0 +1 @@ +export * from "./product-option-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-option-section/product-option-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-option-section/product-option-section.tsx new file mode 100644 index 0000000000000..cba0159b02af8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-option-section/product-option-section.tsx @@ -0,0 +1,18 @@ +import { Product } from "@medusajs/medusa"; +import { Container, Heading } from "@medusajs/ui"; + +type ProductOptionSectionProps = { + product: Product; +}; + +export const ProductOptionSection = ({ + product, +}: ProductOptionSectionProps) => { + return ( +
+ + Options + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/index.ts new file mode 100644 index 0000000000000..9742ed8fb95f5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/index.ts @@ -0,0 +1 @@ +export * from "./product-sales-channel-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/product-sales-channel-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/product-sales-channel-section.tsx new file mode 100644 index 0000000000000..e7cac61138166 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-sales-channel-section/product-sales-channel-section.tsx @@ -0,0 +1,54 @@ +import { Channels } from "@medusajs/icons"; +import { Product } from "@medusajs/medusa"; +import { Container, Heading, Text } from "@medusajs/ui"; +import { useAdminSalesChannels } from "medusa-react"; +import { Trans, useTranslation } from "react-i18next"; + +type ProductSalesChannelSectionProps = { + product: Product; +}; + +export const ProductSalesChannelSection = ({ + product, +}: ProductSalesChannelSectionProps) => { + const { count } = useAdminSalesChannels(); + const { t } = useTranslation(); + + const availableInSalesChannels = + product.sales_channels?.map((sc) => ({ + id: sc.id, + name: sc.name, + })) ?? []; + + return ( +
+ +
+ {t("fields.sales_channels")} +
+
+
+
+ +
+
+
+
+ + , + , + ]} + /> + +
+
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/index.ts new file mode 100644 index 0000000000000..f3fb2a0e2b1ad --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/index.ts @@ -0,0 +1 @@ +export * from "./product-thumbnail-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/product-thumbnail-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/product-thumbnail-section.tsx new file mode 100644 index 0000000000000..7926e0ab91d28 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-thumbnail-section/product-thumbnail-section.tsx @@ -0,0 +1,31 @@ +import { Product } from "@medusajs/medusa"; +import { Container, Heading } from "@medusajs/ui"; + +type ProductThumbnailSectionProps = { + product: Product; +}; + +export const ProductThumbnailSection = ({ + product, +}: ProductThumbnailSectionProps) => { + return ( +
+ +
+ Thumbnail +
+
+ {product.thumbnail && ( +
+ {`${product.title} +
+ )} +
+
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/index.ts b/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/index.ts new file mode 100644 index 0000000000000..f9796823468a4 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/index.ts @@ -0,0 +1 @@ +export * from "./product-variant-section"; diff --git a/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/product-variant-section.tsx b/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/product-variant-section.tsx new file mode 100644 index 0000000000000..8f8f331524e4b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/components/product-variant-section/product-variant-section.tsx @@ -0,0 +1,18 @@ +import { Product } from "@medusajs/medusa"; +import { Container, Heading } from "@medusajs/ui"; + +type ProductVariantSectionProps = { + product: Product; +}; + +export const ProductVariantSection = ({ + product, +}: ProductVariantSectionProps) => { + return ( +
+ + Variants + +
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-details/details.tsx b/packages/admin-next/dashboard/src/routes/products/views/product-details/details.tsx new file mode 100644 index 0000000000000..5139d932ae9ea --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-details/details.tsx @@ -0,0 +1,116 @@ +import { useAdminProduct } from "medusa-react"; +import { useLoaderData, useParams } from "react-router-dom"; + +import { JsonView } from "../../../../components/common/json-view"; +import { ProductAttributeSection } from "../../components/product-attribute-section"; +import { ProductGeneralSection } from "../../components/product-general-section"; +import { ProductMediaSection } from "../../components/product-media-section"; +import { ProductOptionSection } from "../../components/product-option-section"; +import { ProductSalesChannelSection } from "../../components/product-sales-channel-section"; +import { ProductThumbnailSection } from "../../components/product-thumbnail-section"; +import { ProductVariantSection } from "../../components/product-variant-section"; +import { productLoader } from "./loader"; + +import after from "medusa-admin:widgets/product/details/after"; +import before from "medusa-admin:widgets/product/details/before"; +import sideAfter from "medusa-admin:widgets/product/details/side/after"; +import sideBefore from "medusa-admin:widgets/product/details/side/before"; + +export const ProductDetails = () => { + const initialData = useLoaderData() as Awaited< + ReturnType + >; + + const { id } = useParams(); + const { product, isLoading, isError, error } = useAdminProduct( + id!, + undefined, + { + initialData: initialData, + } + ); + + // TODO: Move to loading.tsx and set as Suspense fallback for the route + if (isLoading) { + return
Loading
; + } + + // TODO: Move to error.tsx and set as ErrorBoundary for the route + if (isError || !product) { + const err = error ? JSON.parse(JSON.stringify(error)) : null; + return ( +
+ {(err as Error & { status: number })?.status === 404 ? ( +
Not found
+ ) : ( +
Something went wrong!
+ )} +
+ ); + } + + return ( +
+ {before.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} +
+
+ + + + + +
+ {sideBefore.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} + + + {sideAfter.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} +
+ {after.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} + +
+
+ {sideBefore.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} + + + {sideAfter.widgets.map((w, i) => { + return ( +
+ +
+ ); + })} +
+
+
+ ); +}; diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-details/index.ts b/packages/admin-next/dashboard/src/routes/products/views/product-details/index.ts new file mode 100644 index 0000000000000..a1671c01de64c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-details/index.ts @@ -0,0 +1,2 @@ +export { ProductDetails as Component } from "./details"; +export { productLoader as loader } from "./loader"; diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-details/loader.ts b/packages/admin-next/dashboard/src/routes/products/views/product-details/loader.ts new file mode 100644 index 0000000000000..0f27a0d25fca0 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-details/loader.ts @@ -0,0 +1,21 @@ +import { AdminProductsRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../../lib/medusa" + +const productDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.products.retrieve(id), +}) + +export const productLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = productDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-list/index.ts b/packages/admin-next/dashboard/src/routes/products/views/product-list/index.ts new file mode 100644 index 0000000000000..bad072e574525 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-list/index.ts @@ -0,0 +1,2 @@ +export { ProductList as Component } from "./list"; +export { productsLoader as productLoader } from "./loader"; diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-list/list.tsx b/packages/admin-next/dashboard/src/routes/products/views/product-list/list.tsx new file mode 100644 index 0000000000000..bf546c833b843 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-list/list.tsx @@ -0,0 +1,277 @@ +import type { Product } from "@medusajs/medusa" +import { + Checkbox, + CommandBar, + Container, + DropdownMenu, + Heading, + IconButton, + Table, + Text, + clx, +} from "@medusajs/ui" +import { + PaginationState, + RowSelectionState, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { useAdminDeleteProduct, useAdminProducts } from "medusa-react" +import { useMemo, useState } from "react" +import { useLoaderData, useNavigate } from "react-router-dom" + +import { Thumbnail } from "../../../../components/common/thumbnail" +import { productsLoader } from "./loader" + +import { EllipsisVertical, Trash } from "@medusajs/icons" +import after from "medusa-admin:widgets/product/list/after" +import before from "medusa-admin:widgets/product/list/before" +import { useTranslation } from "react-i18next" + +const PAGE_SIZE = 50 + +export const ProductList = () => { + const navigate = useNavigate() + const { t } = useTranslation() + + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const [rowSelection, setRowSelection] = useState({}) + + const initialData = useLoaderData() as Awaited< + ReturnType> + > + + const { products, count } = useAdminProducts( + { + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE, + }, + { + initialData, + } + ) + + const columns = useColumns() + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const table = useReactTable({ + data: (products ?? []) as Product[], + columns, + pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), + state: { + pagination, + rowSelection, + }, + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + return ( +
+ {before.widgets.map((w, i) => ( + + ))} + +
+ {t("products.domain")} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + navigate(`/products/${row.original.id}`)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ +
+ + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + { + console.log("Delete") + }} + shortcut="d" + label={t("general.delete")} + /> + + +
+ {after.widgets.map((w, i) => ( + + ))} +
+ ) +} + +const ProductActions = ({ id }: { id: string }) => { + const { mutateAsync } = useAdminDeleteProduct(id) + + const handleDelete = async () => { + await mutateAsync() + } + + return ( + + + + + + + + +
+ + Delete +
+
+
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + const columns = useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.accessor("title", { + header: t("fields.title"), + cell: (cell) => { + const title = cell.getValue() + const thumbnail = cell.row.original.thumbnail + + return ( +
+ + + {title} + +
+ ) + }, + }), + columnHelper.accessor("variants", { + header: t("products.variants"), + cell: (cell) => { + const variants = cell.getValue() + + return ( + + {variants.length} + + ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + return + }, + }), + ], + [t] + ) + + return columns +} diff --git a/packages/admin-next/dashboard/src/routes/products/views/product-list/loader.ts b/packages/admin-next/dashboard/src/routes/products/views/product-list/loader.ts new file mode 100644 index 0000000000000..2828984d46645 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/views/product-list/loader.ts @@ -0,0 +1,23 @@ +import { AdminProductsListRes } from "@medusajs/medusa"; +import { Response } from "@medusajs/medusa-js"; +import { QueryClient } from "@tanstack/react-query"; +import { adminProductKeys } from "medusa-react"; + +import { medusa, queryClient } from "../../../../lib/medusa"; + +const productsListQuery = () => ({ + queryKey: adminProductKeys.list({ limit: 20, offset: 0 }), + queryFn: async () => medusa.admin.products.list({ limit: 20, offset: 0 }), +}); + +export const productsLoader = (client: QueryClient) => { + return async () => { + const query = productsListQuery(); + + return ( + queryClient.getQueryData>( + query.queryKey + ) ?? (await client.fetchQuery(query)) + ); + }; +}; diff --git a/packages/admin-next/dashboard/src/routes/profile/components/edit-profile-details-drawer/edit-profile-details-drawer.tsx b/packages/admin-next/dashboard/src/routes/profile/components/edit-profile-details-drawer/edit-profile-details-drawer.tsx new file mode 100644 index 0000000000000..27b4d5684c736 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/profile/components/edit-profile-details-drawer/edit-profile-details-drawer.tsx @@ -0,0 +1,236 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Button, Drawer, Heading, Input, Select, Switch } from "@medusajs/ui" +import { adminAuthKeys, useAdminUpdateUser } from "medusa-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" +import * as zod from "zod" +import { Form } from "../../../../components/common/form" +import { languages } from "../../../../i18n/config" +import { queryClient } from "../../../../lib/medusa" + +const EditProfileDetailsSchema = zod.object({ + first_name: zod.string().optional(), + last_name: zod.string().optional(), + language: zod.string(), + user_insights: zod.boolean(), +}) + +type EditProfileDetailsDrawerProps = { + id: string + firstName?: string + lastName?: string + userInsights?: boolean +} + +export const EditProfileDetailsDrawer = ({ + id, + firstName = "", + lastName = "", + userInsights = false, +}: EditProfileDetailsDrawerProps) => { + const [open, setOpen] = useState(false) + const [selectOpen, setSelectOpen] = useState(false) + const { mutateAsync, isLoading } = useAdminUpdateUser(id) + + const { i18n } = useTranslation() + + const changeLanguage = (code: string) => { + i18n.changeLanguage(code) + } + + const sortedLanguages = languages.sort((a, b) => + a.display_name.localeCompare(b.display_name) + ) + + const form = useForm>({ + defaultValues: { + first_name: firstName, + last_name: lastName, + language: i18n.language, + user_insights: userInsights, + }, + resolver: zodResolver(EditProfileDetailsSchema), + }) + + const { t } = useTranslation() + + const onOpenChange = (open: boolean) => { + if (!open) { + form.reset() + + /** + * If the select is open while closing the drawer, we need to close it as well. + * Otherwise it will cause "pointer-events: none" to stay applied to the body, + * making the page unresponsive. + */ + setSelectOpen(false) + } + + setOpen(open) + } + + const onSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + first_name: values.first_name, + last_name: values.last_name, + }, + { + onSuccess: ({ user }) => { + form.reset({ + first_name: user.first_name, + last_name: user.last_name, + }) + + // Invalidate the current user session. + queryClient.invalidateQueries(adminAuthKeys.details()) + }, + onError: (error) => { + console.log(error) + return + }, + } + ) + + changeLanguage(values.language) + + onOpenChange(false) + }) + + return ( + + + + + + + {t("profile.editProfileDetails")} + + +
+
+
+ ( + + {t("fields.firstName")} + + + + + + )} + /> + ( + + {t("fields.lastName")} + + + + + + )} + /> +
+ ( + +
+ Language + {t("profile.languageHint")} +
+
+ + + + +
+
+ )} + /> + ( + +
+ User Insights + + + +
+ + + , + ]} + /> + + + +
+ )} + /> +
+
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/profile/views/profile-details/index.ts b/packages/admin-next/dashboard/src/routes/profile/views/profile-details/index.ts new file mode 100644 index 0000000000000..0402621d02605 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/profile/views/profile-details/index.ts @@ -0,0 +1 @@ +export { Profile as Component } from "./profile"; diff --git a/packages/admin-next/dashboard/src/routes/profile/views/profile-details/profile.tsx b/packages/admin-next/dashboard/src/routes/profile/views/profile-details/profile.tsx new file mode 100644 index 0000000000000..6e607d3608bfb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/profile/views/profile-details/profile.tsx @@ -0,0 +1,70 @@ +import { Container, Heading, Text } from "@medusajs/ui" + +import { Spinner } from "@medusajs/icons" +import { useAdminGetSession } from "medusa-react" +import { useTranslation } from "react-i18next" +import { languages } from "../../../../i18n/config" +import { EditProfileDetailsDrawer } from "../../components/edit-profile-details-drawer/edit-profile-details-drawer" + +export const Profile = () => { + const { user, isLoading } = useAdminGetSession() + const { i18n, t } = useTranslation() + + if (isLoading || !user) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ {t("profile.domain")} + + {t("profile.manageYourProfileDetails")} + +
+
+ + Name + + + {user.first_name} {user.last_name} + +
+
+ + Email + + + {user.email} + +
+
+ + Language + + + {languages.find((lang) => lang.code === i18n.language) + ?.display_name || "-"} + +
+
+ + Usage insights + +
+
+ +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/components/edit-region-details-drawer/edit-region-details-drawer.tsx b/packages/admin-next/dashboard/src/routes/regions/components/edit-region-details-drawer/edit-region-details-drawer.tsx new file mode 100644 index 0000000000000..8752f83a901ff --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/components/edit-region-details-drawer/edit-region-details-drawer.tsx @@ -0,0 +1,12 @@ +import { Drawer } from "@medusajs/ui" +import { useState } from "react" + +export const EditRegionDetailsDrawer = () => { + const [open, setOpen] = useState(false) + + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/views/region-details/index.ts b/packages/admin-next/dashboard/src/routes/regions/views/region-details/index.ts new file mode 100644 index 0000000000000..cd1dedc129d49 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/views/region-details/index.ts @@ -0,0 +1 @@ +export { RegionDetails as Component } from "./region-details" diff --git a/packages/admin-next/dashboard/src/routes/regions/views/region-details/region-details.tsx b/packages/admin-next/dashboard/src/routes/regions/views/region-details/region-details.tsx new file mode 100644 index 0000000000000..2f00216e38a70 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/views/region-details/region-details.tsx @@ -0,0 +1,142 @@ +import { EllipsisHorizontal } from "@medusajs/icons" +import { + Badge, + Container, + Heading, + IconButton, + Text, + Tooltip, +} from "@medusajs/ui" +import { useAdminRegion } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +export const RegionDetails = () => { + const { id } = useParams() + const { region, isLoading, isError, error } = useAdminRegion(id!) + + const { t } = useTranslation() + + // TODO: Move to loading.tsx and set as Suspense fallback for the route + if (isLoading) { + return
Loading
+ } + + // TODO: Move to error.tsx and set as ErrorBoundary for the route + if (isError || !region) { + const err = error ? JSON.parse(JSON.stringify(error)) : null + return ( +
+ {(err as Error & { status: number })?.status === 404 ? ( +
Not found
+ ) : ( +
Something went wrong!
+ )} +
+ ) + } + + return ( +
+ +
+
+ {region.name} +
+ + {region.countries + .slice(0, 2) + .map((c) => c.display_name) + .join(", ")} + + {region.countries.length > 2 && ( + + {region.countries.slice(2).map((c) => ( +
  • {c.display_name}
  • + ))} + + } + > + + {t("general.plusCountMore", { + count: region.countries.length - 2, + })} + +
    + )} +
    +
    + + + +
    +
    + + Currency + +
    + + {region.currency_code} + + + {region.currency.name} + +
    +
    +
    + + Default Tax Rate + + + {region.tax_rate} + +
    +
    + + Default Tax Code + + + {region.tax_code ?? "-"} + +
    +
    + + Tax Inclusive Pricing + + + {region.includes_tax ? t("general.enabled") : t("general.disabled")} + +
    +
    + + Payment Providers + + + {region.payment_providers.length > 0 + ? region.payment_providers.map((p) => p.id).join(", ") + : "-"} + +
    +
    + + Fulfillment Providers + + + {region.fulfillment_providers.length > 0 + ? region.fulfillment_providers.map((p) => p.id).join(", ") + : "-"} + +
    +
    + + +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/regions/views/region-list/index.ts b/packages/admin-next/dashboard/src/routes/regions/views/region-list/index.ts new file mode 100644 index 0000000000000..a37eced3be819 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/views/region-list/index.ts @@ -0,0 +1 @@ +export { RegionList as Component } from "./region-list" diff --git a/packages/admin-next/dashboard/src/routes/regions/views/region-list/region-list.tsx b/packages/admin-next/dashboard/src/routes/regions/views/region-list/region-list.tsx new file mode 100644 index 0000000000000..84c4592430788 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/regions/views/region-list/region-list.tsx @@ -0,0 +1,26 @@ +import { useAdminRegions } from "medusa-react" +import { Link } from "react-router-dom" + +export const RegionList = () => { + const { regions, isLoading, isError, error } = useAdminRegions() + + if (isLoading) { + return
    Loading...
    + } + + if (isError || !regions) { + return
    Error
    + } + + return ( +
    + {regions.map((region) => { + return ( +
    + {region.name} +
    + ) + })} +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/index.ts b/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/index.ts new file mode 100644 index 0000000000000..a2677176a737f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/index.ts @@ -0,0 +1 @@ +export * from "./sales-channel-details-section" diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/sales-channel-details-section.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/sales-channel-details-section.tsx new file mode 100644 index 0000000000000..559c432748f67 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/sales-channels/components/sales-channel-details-section/sales-channel-details-section.tsx @@ -0,0 +1,200 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons" +import { SalesChannel } from "@medusajs/medusa" +import { + Button, + Container, + Drawer, + DropdownMenu, + Heading, + IconButton, + Input, + StatusBadge, + Switch, + Text, + Textarea, +} from "@medusajs/ui" +import { useAdminUpdateSalesChannel } from "medusa-react" +import { 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" + +type Props = { + salesChannel: SalesChannel +} + +export const SalesChannelDetailsSection = ({ salesChannel }: Props) => { + const [drawerOpen, setDrawerOpen] = useState(false) + const { t } = useTranslation() + + return ( +
    + +
    +
    + {salesChannel.name} + + {salesChannel.description} + +
    +
    + + {t( + `general.${salesChannel.is_disabled ? "disabled" : "enabled"}` + )} + + + + + + + + + setDrawerOpen(!drawerOpen)} + > + + {t("general.edit")} + + + + + {t("general.delete")} + + + +
    +
    +
    + +
    + ) +} + +const EditSalesChannelDetailsSchema = zod.object({ + name: zod.string().min(1), + description: zod.string().optional(), + is_active: zod.boolean(), +}) + +type DrawerProps = { + salesChannel: SalesChannel + open: boolean + setOpen: (open: boolean) => void +} + +const EditSalesChannelDetailsDrawer = ({ + salesChannel, + open, + setOpen, +}: DrawerProps) => { + const form = useForm>({ + defaultValues: { + name: salesChannel.name, + description: salesChannel.description ?? "", + is_active: !salesChannel.is_disabled, + }, + resolver: zodResolver(EditSalesChannelDetailsSchema), + }) + const { mutateAsync, isLoading } = useAdminUpdateSalesChannel(salesChannel.id) + const { t } = useTranslation() + + const onSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + name: values.name, + description: values.description ?? undefined, + is_disabled: !values.is_active, + }, + { + onSuccess: () => { + setOpen(false) + }, + } + ) + }) + + return ( + +
    + + + {t("salesChannels.editSalesChannel")} + + + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.description")} + +