feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)
**What** - Renames /admin-next -> /admin - Renames @medusajs/admin-sdk -> @medusajs/admin-bundler - Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency. - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`. - Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
1 parent
beaa851302
commit
0fe1201435
1440 files changed
+122
-86
No files matched your search
@@ -0,0 +1 @@
|
||||
# `@medusajs/admin-bundler`
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@medusajs/admin-bundler",
|
||||
"version": "0.0.1",
|
||||
"description": "Bundler for the Medusa admin dashboard.",
|
||||
"author": "Kasper Kristensen <kasper@medusajs.com>",
|
||||
"scripts": {
|
||||
"build": "tsup && copyfiles -f ./src/index.html ./src/entry.tsx ./src/index.css ./dist"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/admin/admin-bundler"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@medusajs/types": "^1.11.16",
|
||||
"@types/compression": "^1.7.5",
|
||||
"copyfiles": "^2.4.1",
|
||||
"express": "^4.18.2",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/admin-shared": "0.0.1",
|
||||
"@medusajs/admin-vite-plugin": "0.0.1",
|
||||
"@medusajs/dashboard": "0.0.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"compression": "^1.7.4",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4.18.2",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import App from "@medusajs/dashboard"
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import "./index.css"
|
||||
|
||||
const container = document.getElementById("root")
|
||||
const root = createRoot(container!)
|
||||
root.render(<App />)
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "@medusajs/dashboard/css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="icon" href="data:," data-placeholder-favicon />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./entry.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { build } from "./lib/build"
|
||||
export { develop } from "./lib/develop"
|
||||
export { serve } from "./lib/serve"
|
||||
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InlineConfig } from "vite"
|
||||
import { BundlerOptions } from "../types"
|
||||
import { getViteConfig } from "./config"
|
||||
|
||||
export async function build(options: BundlerOptions) {
|
||||
const vite = await import("vite")
|
||||
|
||||
const viteConfig = await getViteConfig(options)
|
||||
const buildConfig: InlineConfig = {
|
||||
mode: "production",
|
||||
logLevel: "error",
|
||||
}
|
||||
|
||||
await vite.build(vite.mergeConfig(viteConfig, buildConfig))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { VIRTUAL_MODULES } from "@medusajs/admin-shared"
|
||||
import path from "path"
|
||||
import { Config } from "tailwindcss"
|
||||
import type { InlineConfig } from "vite"
|
||||
|
||||
import { BundlerOptions } from "../types"
|
||||
|
||||
export async function getViteConfig(
|
||||
options: BundlerOptions
|
||||
): Promise<InlineConfig> {
|
||||
const { searchForWorkspaceRoot, mergeConfig } = await import("vite")
|
||||
const { default: react } = await import("@vitejs/plugin-react")
|
||||
const { default: medusa } = await import("@medusajs/admin-vite-plugin")
|
||||
|
||||
const getPort = await import("get-port")
|
||||
const hmrPort = await getPort.default()
|
||||
|
||||
const root = path.resolve(__dirname, "./")
|
||||
|
||||
const backendUrl = options.backendUrl ?? ""
|
||||
const storefrontUrl = options.storefrontUrl ?? ""
|
||||
|
||||
const baseConfig: InlineConfig = {
|
||||
root,
|
||||
base: options.path,
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
outDir: path.resolve(process.cwd(), options.outDir),
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@medusajs/dashboard", "react-dom/client"],
|
||||
exclude: VIRTUAL_MODULES,
|
||||
},
|
||||
define: {
|
||||
__BASE__: JSON.stringify(options.path),
|
||||
__BACKEND_URL__: JSON.stringify(backendUrl),
|
||||
__STOREFRONT_URL__: JSON.stringify(storefrontUrl),
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [searchForWorkspaceRoot(process.cwd())],
|
||||
},
|
||||
hmr: {
|
||||
port: hmrPort,
|
||||
},
|
||||
middlewareMode: true,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
require("tailwindcss")({
|
||||
config: createTailwindConfig(root, options.sources),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
medusa({
|
||||
sources: options.sources,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
if (options.vite) {
|
||||
const customConfig = options.vite(baseConfig)
|
||||
|
||||
return mergeConfig(baseConfig, customConfig)
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
function createTailwindConfig(entry: string, sources: string[] = []) {
|
||||
const root = path.join(entry, "**/*.{js,ts,jsx,tsx}")
|
||||
const html = path.join(entry, "index.html")
|
||||
|
||||
let dashboard = ""
|
||||
|
||||
try {
|
||||
dashboard = path.join(
|
||||
path.dirname(require.resolve("@medusajs/dashboard")),
|
||||
"**/*.{js,ts,jsx,tsx}"
|
||||
)
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let ui: string = ""
|
||||
|
||||
try {
|
||||
ui = path.join(
|
||||
path.dirname(require.resolve("@medusajs/ui")),
|
||||
"**/*.{js,ts,jsx,tsx}"
|
||||
)
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const extensions = sources.map((s) => path.join(s, "**/*.{js,ts,jsx,tsx}"))
|
||||
|
||||
const config: Config = {
|
||||
presets: [require("@medusajs/ui-preset")],
|
||||
content: [html, root, dashboard, ui, ...extensions],
|
||||
darkMode: "class",
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import express from "express"
|
||||
import type { InlineConfig } from "vite"
|
||||
|
||||
import { BundlerOptions } from "../types"
|
||||
import { getViteConfig } from "./config"
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
export async function develop(options: BundlerOptions) {
|
||||
const vite = await import("vite")
|
||||
|
||||
try {
|
||||
const viteConfig = await getViteConfig(options)
|
||||
|
||||
const developConfig: InlineConfig = {
|
||||
mode: "development",
|
||||
logLevel: "warn",
|
||||
}
|
||||
|
||||
const server = await vite.createServer(
|
||||
vite.mergeConfig(viteConfig, developConfig)
|
||||
)
|
||||
|
||||
router.use(server.middlewares)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw new Error(
|
||||
"Failed to start admin development server. See error above."
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import compression from "compression"
|
||||
import { Request, Response, Router, static as static_ } from "express"
|
||||
import fs from "fs"
|
||||
import { ServerResponse } from "http"
|
||||
import path from "path"
|
||||
|
||||
type ServeOptions = {
|
||||
outDir: string
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
export async function serve(options: ServeOptions) {
|
||||
const htmlPath = path.resolve(options.outDir, "index.html")
|
||||
|
||||
/**
|
||||
* The admin UI should always be built at this point, but in the
|
||||
* rare case that another plugin terminated a previous startup, the admin
|
||||
* may not have been built correctly. Here we check if the admin UI
|
||||
* build files exist, and if not, we throw an error, providing the
|
||||
* user with instructions on how to fix their build.
|
||||
*/
|
||||
|
||||
const indexExists = fs.existsSync(htmlPath)
|
||||
|
||||
if (!indexExists) {
|
||||
throw new Error(
|
||||
`Could not find index.html in the admin build directory. Make sure to run 'medusa build' before starting the server.`
|
||||
)
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(htmlPath, "utf-8")
|
||||
|
||||
const sendHtml = (_req: Request, res: Response) => {
|
||||
res.setHeader("Cache-Control", "no-cache")
|
||||
res.setHeader("Vary", "Origin, Cache-Control")
|
||||
res.send(html)
|
||||
}
|
||||
|
||||
const setStaticHeaders = (res: ServerResponse) => {
|
||||
res.setHeader("Cache-Control", "max-age=31536000, immutable")
|
||||
res.setHeader("Vary", "Origin, Cache-Control")
|
||||
}
|
||||
|
||||
router.use(compression())
|
||||
|
||||
router.get("/", sendHtml)
|
||||
router.use(
|
||||
static_(options.outDir, {
|
||||
setHeaders: setStaticHeaders,
|
||||
})
|
||||
)
|
||||
router.get(`/*`, sendHtml)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AdminOptions } from "@medusajs/types"
|
||||
|
||||
export type BundlerOptions = Required<Pick<AdminOptions, "outDir" | "path">> &
|
||||
Pick<AdminOptions, "vite" | "backendUrl" | "storefrontUrl"> & {
|
||||
sources?: string[]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"declarationMap": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["tsup.config.cjs", "node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
# @medusajs/admin-sdk
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@medusajs/admin-sdk",
|
||||
"description": "SDK for building extension for the Medusa admin dashboard.",
|
||||
"version": "0.0.1",
|
||||
"author": "Kasper Kristensen <kasper@medusajs.com>",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/admin/admin-sdk"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.2",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/admin-shared": "^0.0.1"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { InjectionZone } from "@medusajs/admin-shared"
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
export type WidgetConfig = {
|
||||
zone: InjectionZone | InjectionZone[]
|
||||
}
|
||||
|
||||
export type RouteConfig = {
|
||||
label?: string
|
||||
icon?: ComponentType
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RouteConfig, WidgetConfig } from "./types"
|
||||
|
||||
function createConfigHelper<TConfig extends Record<string, unknown>>(
|
||||
config: TConfig
|
||||
): TConfig {
|
||||
return {
|
||||
...config,
|
||||
/**
|
||||
* This property is required to allow the config to be exported,
|
||||
* while still allowing HMR to work correctly.
|
||||
*
|
||||
* It tricks Fast Refresh into thinking that the config is a React component,
|
||||
* which allows it to be updated without a full page reload.
|
||||
*/
|
||||
$$typeof: Symbol.for("react.memo"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a widget configuration.
|
||||
*
|
||||
* @param config The widget configuration.
|
||||
* @returns The widget configuration.
|
||||
*/
|
||||
export function defineWidgetConfig(config: WidgetConfig) {
|
||||
return createConfigHelper(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a route configuration.
|
||||
*
|
||||
* @param config The route configuration.
|
||||
* @returns The route configuration.
|
||||
*/
|
||||
export function defineRouteConfig(config: RouteConfig) {
|
||||
return createConfigHelper(config)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./config"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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",
|
||||
"declarationMap": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
# @medusajs/admin-shared
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@medusajs/admin-shared",
|
||||
"description": "Shared code for Medusa admin packages.",
|
||||
"version": "0.0.1",
|
||||
"author": "Kasper Kristensen <kasper@medusajs.com>",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/admin/admin-shared"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ROUTE_IMPORTS = ["routes/pages", "routes/links"] as const
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ROUTE_IMPORTS } from "./constants"
|
||||
|
||||
export type RouteImport = (typeof ROUTE_IMPORTS)[number]
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ROUTE_IMPORTS } from "../routes"
|
||||
import { INJECTION_ZONES } from "../widgets"
|
||||
import { getVirtualId, getWidgetImport, resolveVirtualId } from "./utils"
|
||||
|
||||
const VIRTUAL_WIDGET_MODULES = INJECTION_ZONES.map((zone) => {
|
||||
return getVirtualId(getWidgetImport(zone))
|
||||
})
|
||||
|
||||
const VIRTUAL_ROUTE_MODULES = ROUTE_IMPORTS.map((route) => {
|
||||
return getVirtualId(route)
|
||||
})
|
||||
|
||||
/**
|
||||
* All virtual modules that are used in the admin panel. Virtual modules are used
|
||||
* to inject custom widgets, routes and settings. A virtual module is imported using
|
||||
* a string that corresponds to the id of the virtual module.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import ProductDetailsBefore from "virtual:medusa/widgets/product/details/before"
|
||||
* ```
|
||||
*/
|
||||
export const VIRTUAL_MODULES = [
|
||||
...VIRTUAL_WIDGET_MODULES,
|
||||
...VIRTUAL_ROUTE_MODULES,
|
||||
]
|
||||
|
||||
/**
|
||||
* Reolved paths to all virtual widget modules.
|
||||
*/
|
||||
export const RESOLVED_WIDGET_MODULES = VIRTUAL_WIDGET_MODULES.map((id) => {
|
||||
return resolveVirtualId(id)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reolved paths to all virtual route modules.
|
||||
*/
|
||||
export const RESOLVED_ROUTE_MODULES = VIRTUAL_ROUTE_MODULES.map((id) => {
|
||||
return resolveVirtualId(id)
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./constants"
|
||||
export * from "./utils"
|
||||
@@ -0,0 +1,25 @@
|
||||
import { InjectionZone } from "../widgets"
|
||||
|
||||
const PREFIX = "virtual:medusa/"
|
||||
|
||||
export const getVirtualId = (name: string) => {
|
||||
return `${PREFIX}${name}`
|
||||
}
|
||||
|
||||
export const resolveVirtualId = (id: string) => {
|
||||
return `\0${id}`
|
||||
}
|
||||
|
||||
export const getWidgetImport = (zone: InjectionZone) => {
|
||||
return `widgets/${zone.replace(/\./g, "/")}`
|
||||
}
|
||||
|
||||
export const getWidgetZone = (resolvedId: string): InjectionZone => {
|
||||
const virtualPrefix = `\0${PREFIX}widgets/`
|
||||
|
||||
const zone = resolvedId
|
||||
.replace(virtualPrefix, "")
|
||||
.replace(/\//g, ".") as InjectionZone
|
||||
|
||||
return zone as InjectionZone
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
const ORDER_INJECTION_ZONES = [
|
||||
"order.details.before",
|
||||
"order.details.after",
|
||||
"order.details.side.before",
|
||||
"order.details.side.after",
|
||||
"order.list.before",
|
||||
"order.list.after",
|
||||
] as const
|
||||
|
||||
const DRAFT_ORDER_INJECTION_ZONES = [
|
||||
"draft_order.list.before",
|
||||
"draft_order.list.after",
|
||||
"draft_order.details.side.before",
|
||||
"draft_order.details.side.after",
|
||||
"draft_order.details.before",
|
||||
"draft_order.details.after",
|
||||
] as const
|
||||
|
||||
const CUSTOMER_INJECTION_ZONES = [
|
||||
"customer.details.before",
|
||||
"customer.details.after",
|
||||
"customer.list.before",
|
||||
"customer.list.after",
|
||||
] as const
|
||||
|
||||
const CUSTOMER_GROUP_INJECTION_ZONES = [
|
||||
"customer_group.details.before",
|
||||
"customer_group.details.after",
|
||||
"customer_group.list.before",
|
||||
"customer_group.list.after",
|
||||
] as const
|
||||
|
||||
const PRODUCT_INJECTION_ZONES = [
|
||||
"product.details.before",
|
||||
"product.details.after",
|
||||
"product.list.before",
|
||||
"product.list.after",
|
||||
"product.details.side.before",
|
||||
"product.details.side.after",
|
||||
] as const
|
||||
|
||||
const PRODUCT_COLLECTION_INJECTION_ZONES = [
|
||||
"product_collection.details.before",
|
||||
"product_collection.details.after",
|
||||
"product_collection.list.before",
|
||||
"product_collection.list.after",
|
||||
] as const
|
||||
|
||||
const PRODUCT_CATEGORY_INJECTION_ZONES = [
|
||||
"product_category.details.before",
|
||||
"product_category.details.after",
|
||||
"product_category.details.side.before",
|
||||
"product_category.details.side.after",
|
||||
"product_category.list.before",
|
||||
"product_category.list.after",
|
||||
] as const
|
||||
|
||||
const PRODUCT_TYPE_INJECTION_ZONES = [
|
||||
"product_type.details.before",
|
||||
"product_type.details.after",
|
||||
"product_type.list.before",
|
||||
"product_type.list.after",
|
||||
] as const
|
||||
|
||||
const PRODUCT_TAG_INJECTION_ZONES = [
|
||||
"product_tag.details.before",
|
||||
"product_tag.details.after",
|
||||
"product_tag.list.before",
|
||||
"product_tag.list.after",
|
||||
] as const
|
||||
|
||||
const PRICE_LIST_INJECTION_ZONES = [
|
||||
"price_list.details.before",
|
||||
"price_list.details.after",
|
||||
"price_list.details.side.before",
|
||||
"price_list.details.side.after",
|
||||
"price_list.list.before",
|
||||
"price_list.list.after",
|
||||
] as const
|
||||
|
||||
const PROMOTION_INJECTION_ZONES = [
|
||||
"promotion.details.before",
|
||||
"promotion.details.after",
|
||||
"promotion.details.side.before",
|
||||
"promotion.details.side.after",
|
||||
"promotion.list.before",
|
||||
"promotion.list.after",
|
||||
] as const
|
||||
|
||||
const CAMPAIGN_INJECTION_ZONES = [
|
||||
"campaign.details.before",
|
||||
"campaign.details.after",
|
||||
"campaign.details.side.before",
|
||||
"campaign.details.side.after",
|
||||
"campaign.list.before",
|
||||
"campaign.list.after",
|
||||
] as const
|
||||
|
||||
const 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",
|
||||
] as const
|
||||
|
||||
const USER_INJECTION_ZONES = [
|
||||
"user.details.before",
|
||||
"user.details.after",
|
||||
"user.list.before",
|
||||
"user.list.after",
|
||||
] as const
|
||||
|
||||
const STORE_INJECTION_ZONES = [
|
||||
"store.details.before",
|
||||
"store.details.after",
|
||||
] as const
|
||||
|
||||
const PROFILE_INJECTION_ZONES = [
|
||||
"profile.details.before",
|
||||
"profile.details.after",
|
||||
] as const
|
||||
|
||||
const REGION_INJECTION_ZONES = [
|
||||
"region.details.before",
|
||||
"region.details.after",
|
||||
"region.list.before",
|
||||
"region.list.after",
|
||||
] as const
|
||||
|
||||
const SHIPPING_PROFILE_INJECTION_ZONES = [
|
||||
"shipping_profile.details.before",
|
||||
"shipping_profile.details.after",
|
||||
"shipping_profile.list.before",
|
||||
"shipping_profile.list.after",
|
||||
] as const
|
||||
|
||||
const LOCATION_INJECTION_ZONES = [
|
||||
"location.details.before",
|
||||
"location.details.after",
|
||||
"location.details.side.before",
|
||||
"location.details.side.after",
|
||||
"location.list.before",
|
||||
"location.list.after",
|
||||
] as const
|
||||
|
||||
const LOGIN_INJECTION_ZONES = ["login.before", "login.after"] as const
|
||||
|
||||
const SALES_CHANNEL_INJECTION_ZONES = [
|
||||
"sales_channel.details.before",
|
||||
"sales_channel.details.after",
|
||||
"sales_channel.list.before",
|
||||
"sales_channel.list.after",
|
||||
] as const
|
||||
|
||||
const RESERVATION_INJECTION_ZONES = [
|
||||
"reservation.details.before",
|
||||
"reservation.details.after",
|
||||
"reservation.details.side.before",
|
||||
"reservation.details.side.after",
|
||||
"reservation.list.before",
|
||||
"reservation.list.after",
|
||||
] as const
|
||||
|
||||
const API_KEY_INJECTION_ZONES = [
|
||||
"api_key.details.before",
|
||||
"api_key.details.after",
|
||||
"api_key.list.before",
|
||||
"api_key.list.after",
|
||||
] as const
|
||||
|
||||
const WORKFLOW_INJECTION_ZONES = [
|
||||
"workflow.details.before",
|
||||
"workflow.details.after",
|
||||
"workflow.list.before",
|
||||
"workflow.list.after",
|
||||
] as const
|
||||
|
||||
const TAX_INJECTION_ZONES = [
|
||||
"tax.details.before",
|
||||
"tax.details.after",
|
||||
"tax.list.before",
|
||||
"tax.list.after",
|
||||
] as const
|
||||
|
||||
const RETURN_REASON_INJECTION_ZONES = [
|
||||
"return_reason.list.before",
|
||||
"return_reason.list.after",
|
||||
] as const
|
||||
|
||||
const INVENTORY_ITEM_INJECTION_ZONES = [
|
||||
"inventory_item.details.before",
|
||||
"inventory_item.details.after",
|
||||
"inventory_item.details.side.before",
|
||||
"inventory_item.details.side.after",
|
||||
"inventory_item.list.before",
|
||||
"inventory_item.list.after",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* All valid injection zones in the admin panel. An injection zone is a specific place
|
||||
* in the admin panel where a plugin can inject custom widgets.
|
||||
*/
|
||||
export const INJECTION_ZONES = [
|
||||
...ORDER_INJECTION_ZONES,
|
||||
...DRAFT_ORDER_INJECTION_ZONES,
|
||||
...CUSTOMER_INJECTION_ZONES,
|
||||
...CUSTOMER_GROUP_INJECTION_ZONES,
|
||||
...PRODUCT_INJECTION_ZONES,
|
||||
...PRODUCT_COLLECTION_INJECTION_ZONES,
|
||||
...PRODUCT_CATEGORY_INJECTION_ZONES,
|
||||
...PRICE_LIST_INJECTION_ZONES,
|
||||
...PROMOTION_INJECTION_ZONES,
|
||||
...GIFT_CARD_INJECTION_ZONES,
|
||||
...USER_INJECTION_ZONES,
|
||||
...STORE_INJECTION_ZONES,
|
||||
...PROFILE_INJECTION_ZONES,
|
||||
...REGION_INJECTION_ZONES,
|
||||
...SHIPPING_PROFILE_INJECTION_ZONES,
|
||||
...LOCATION_INJECTION_ZONES,
|
||||
...LOGIN_INJECTION_ZONES,
|
||||
...SALES_CHANNEL_INJECTION_ZONES,
|
||||
...RESERVATION_INJECTION_ZONES,
|
||||
...API_KEY_INJECTION_ZONES,
|
||||
...WORKFLOW_INJECTION_ZONES,
|
||||
...CAMPAIGN_INJECTION_ZONES,
|
||||
...TAX_INJECTION_ZONES,
|
||||
...PRODUCT_TYPE_INJECTION_ZONES,
|
||||
...PRODUCT_TAG_INJECTION_ZONES,
|
||||
...RETURN_REASON_INJECTION_ZONES,
|
||||
...INVENTORY_ITEM_INJECTION_ZONES,
|
||||
] as const
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
@@ -0,0 +1,3 @@
|
||||
import { INJECTION_ZONES } from "./constants"
|
||||
|
||||
export type InjectionZone = (typeof INJECTION_ZONES)[number]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { INJECTION_ZONES } from "./constants"
|
||||
import { InjectionZone } from "./types"
|
||||
|
||||
/**
|
||||
* Validates that the provided zone is a valid injection zone for a widget.
|
||||
*/
|
||||
export function isValidInjectionZone(zone: any): zone is InjectionZone {
|
||||
return INJECTION_ZONES.includes(zone)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./extensions/virtual"
|
||||
export * from "./extensions/widgets"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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",
|
||||
"declarationMap": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["./src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
# @medusajs/admin-vite-plugin
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@medusajs/admin-vite-plugin",
|
||||
"version": "0.0.1",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/admin/admin-vite-plugin"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"watch": "tsup --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "7.22.5",
|
||||
"@types/babel__traverse": "7.20.5",
|
||||
"@types/node": "^20.10.4",
|
||||
"tsup": "8.0.1",
|
||||
"typescript": "5.3.3",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "7.23.5",
|
||||
"@babel/traverse": "7.23.5",
|
||||
"@medusajs/admin-shared": "0.0.1",
|
||||
"chokidar": "3.5.3",
|
||||
"fdir": "6.1.1",
|
||||
"magic-string": "0.30.5"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { parse, type ParseResult, type ParserOptions } from "@babel/parser"
|
||||
import _traverse, { type NodePath } from "@babel/traverse"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
ObjectProperty,
|
||||
} from "@babel/types"
|
||||
|
||||
/**
|
||||
* Depending on whether we are running the CJS or ESM build of the plugin, we
|
||||
* need to import the default export of the `@babel/traverse` package in
|
||||
* different ways.
|
||||
*/
|
||||
let traverse: typeof _traverse
|
||||
|
||||
if (typeof _traverse === "function") {
|
||||
traverse = _traverse
|
||||
} else {
|
||||
traverse = (_traverse as any).default
|
||||
}
|
||||
|
||||
export { parse, traverse }
|
||||
export type {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
NodePath,
|
||||
ObjectProperty,
|
||||
ParseResult,
|
||||
ParserOptions,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin"
|
||||
|
||||
export default medusaVitePlugin
|
||||
export type { MedusaVitePlugin }
|
||||
@@ -0,0 +1,830 @@
|
||||
import {
|
||||
InjectionZone,
|
||||
RESOLVED_ROUTE_MODULES,
|
||||
RESOLVED_WIDGET_MODULES,
|
||||
VIRTUAL_MODULES,
|
||||
getVirtualId,
|
||||
getWidgetImport,
|
||||
getWidgetZone,
|
||||
isValidInjectionZone,
|
||||
resolveVirtualId,
|
||||
} from "@medusajs/admin-shared"
|
||||
import { fdir } from "fdir"
|
||||
import fs from "fs/promises"
|
||||
import MagicString from "magic-string"
|
||||
import path from "path"
|
||||
import type * as Vite from "vite"
|
||||
|
||||
import {
|
||||
ExportNamedDeclaration,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
traverse,
|
||||
type ExportDefaultDeclaration,
|
||||
type File,
|
||||
type NodePath,
|
||||
type ParseResult,
|
||||
type ParserOptions,
|
||||
} from "./babel"
|
||||
|
||||
const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"]
|
||||
|
||||
function convertToImportPath(file: string) {
|
||||
return path.normalize(file).split(path.sep).join("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the module type of a given file.
|
||||
*/
|
||||
function getModuleType(file: string) {
|
||||
const normalizedPath = convertToImportPath(file)
|
||||
|
||||
if (normalizedPath.includes("/admin/widgets/")) {
|
||||
return "widget"
|
||||
} else if (normalizedPath.includes("/admin/routes/")) {
|
||||
return "route"
|
||||
} else {
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parser options for a given file.
|
||||
*/
|
||||
function getParserOptions(file: string): ParserOptions {
|
||||
const options: ParserOptions = {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx"],
|
||||
}
|
||||
|
||||
if (file.endsWith(".tsx")) {
|
||||
options.plugins?.push("typescript")
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a module with a source map from a code string
|
||||
*/
|
||||
function generateModule(code: string) {
|
||||
const magicString = new MagicString(code)
|
||||
|
||||
return {
|
||||
code: magicString.toString(),
|
||||
map: magicString.generateMap({ hires: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawls a directory and returns all files that match the criteria.
|
||||
*/
|
||||
async function crawl(
|
||||
dir: string,
|
||||
file?: string,
|
||||
depth?: { min: number; max?: number }
|
||||
) {
|
||||
const dirDepth = dir.split(path.sep).length
|
||||
|
||||
const crawler = new fdir()
|
||||
.withBasePath()
|
||||
.exclude((dirName) => dirName.startsWith("_"))
|
||||
.filter((path) => {
|
||||
return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(ext))
|
||||
})
|
||||
|
||||
if (file) {
|
||||
crawler.filter((path) => {
|
||||
return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(file + ext))
|
||||
})
|
||||
}
|
||||
|
||||
if (depth) {
|
||||
crawler.filter((file) => {
|
||||
const pathDepth = file.split(path.sep).length - 1
|
||||
|
||||
if (depth.max && pathDepth > dirDepth + depth.max) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (pathDepth < dirDepth + depth.min) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return crawler.crawl(dir).withPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and returns the properties of a `config` object from a named export declaration.
|
||||
*/
|
||||
function getConfigObjectProperties(path: NodePath<ExportNamedDeclaration>) {
|
||||
const declaration = path.node.declaration
|
||||
|
||||
if (declaration && declaration.type === "VariableDeclaration") {
|
||||
const configDeclaration = declaration.declarations.find(
|
||||
(d) =>
|
||||
d.type === "VariableDeclarator" &&
|
||||
d.id.type === "Identifier" &&
|
||||
d.id.name === "config"
|
||||
)
|
||||
|
||||
if (
|
||||
configDeclaration &&
|
||||
configDeclaration.init?.type === "CallExpression" &&
|
||||
configDeclaration.init.arguments.length > 0 &&
|
||||
configDeclaration.init.arguments[0].type === "ObjectExpression"
|
||||
) {
|
||||
return configDeclaration.init.arguments[0].properties
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the default export in a given AST is a component (JSX element or fragment).
|
||||
*/
|
||||
function isDefaultExportComponent(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
ast: File
|
||||
): boolean {
|
||||
let hasComponentExport = false
|
||||
const declaration = path.node.declaration
|
||||
|
||||
if (
|
||||
declaration &&
|
||||
(declaration.type === "Identifier" ||
|
||||
declaration.type === "FunctionDeclaration")
|
||||
) {
|
||||
const exportName =
|
||||
declaration.type === "Identifier"
|
||||
? declaration.name
|
||||
: declaration.id && declaration.id.name
|
||||
|
||||
if (exportName) {
|
||||
try {
|
||||
traverse(ast, {
|
||||
VariableDeclarator({ node, scope }) {
|
||||
let isDefaultExport = false
|
||||
|
||||
if (node.id.type === "Identifier" && node.id.name === exportName) {
|
||||
isDefaultExport = true
|
||||
}
|
||||
|
||||
if (!isDefaultExport) {
|
||||
return
|
||||
}
|
||||
|
||||
traverse(
|
||||
node,
|
||||
{
|
||||
ReturnStatement(path) {
|
||||
if (
|
||||
path.node.argument?.type === "JSXElement" ||
|
||||
path.node.argument?.type === "JSXFragment"
|
||||
) {
|
||||
hasComponentExport = true
|
||||
}
|
||||
},
|
||||
},
|
||||
scope
|
||||
)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasComponentExport
|
||||
}
|
||||
|
||||
/** Widget utilities */
|
||||
|
||||
/**
|
||||
* Validates the widget configuration.
|
||||
*/
|
||||
function validateWidgetConfig(
|
||||
path: NodePath<ExportNamedDeclaration>,
|
||||
zone?: InjectionZone
|
||||
): { zoneIsValid: boolean; zoneValue: string | string[] | null } {
|
||||
let zoneIsValid = false
|
||||
let zoneValue: string | string[] | null = null
|
||||
|
||||
const properties = getConfigObjectProperties(path)
|
||||
|
||||
if (!properties) {
|
||||
return { zoneIsValid, zoneValue }
|
||||
}
|
||||
|
||||
const zoneProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "zone"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
return { zoneIsValid, zoneValue }
|
||||
}
|
||||
|
||||
if (zoneProperty.value.type === "StringLiteral") {
|
||||
zoneIsValid = !zone
|
||||
? isValidInjectionZone(zoneProperty.value.value)
|
||||
: zone === zoneProperty.value.value
|
||||
zoneValue = zoneProperty.value.value
|
||||
} else if (zoneProperty.value.type === "ArrayExpression") {
|
||||
zoneIsValid = zoneProperty.value.elements.every((e) => {
|
||||
if (!e || e.type !== "StringLiteral") {
|
||||
return false
|
||||
}
|
||||
|
||||
const isZoneMatch = !zone ? true : zone === e.value
|
||||
|
||||
return isValidInjectionZone(e.value) && isZoneMatch
|
||||
})
|
||||
|
||||
const values: string[] = []
|
||||
|
||||
for (const element of zoneProperty.value.elements) {
|
||||
if (element && element.type === "StringLiteral") {
|
||||
values.push(element.value)
|
||||
}
|
||||
}
|
||||
|
||||
zoneValue = values
|
||||
}
|
||||
|
||||
return { zoneIsValid, zoneValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a widget file.
|
||||
*/
|
||||
async function validateWidget(
|
||||
file: string,
|
||||
zone?: InjectionZone
|
||||
): Promise<
|
||||
{ valid: true; zone: InjectionZone } | { valid: false; zone: null }
|
||||
> {
|
||||
let _zoneValue: string | string[] | null = null
|
||||
|
||||
const content = await fs.readFile(file, "utf-8")
|
||||
const parserOptions = getParserOptions(file)
|
||||
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, parserOptions)
|
||||
} catch (e) {
|
||||
return { valid: false, zone: _zoneValue }
|
||||
}
|
||||
|
||||
let hasDefaultExport = false
|
||||
let hasNamedExport = false
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
hasDefaultExport = isDefaultExportComponent(path, ast)
|
||||
},
|
||||
ExportNamedDeclaration(path) {
|
||||
const { zoneIsValid, zoneValue } = validateWidgetConfig(path, zone)
|
||||
|
||||
hasNamedExport = zoneIsValid
|
||||
_zoneValue = zoneValue
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return { valid: false, zone: _zoneValue }
|
||||
}
|
||||
|
||||
return { valid: hasNamedExport && hasDefaultExport, zone: _zoneValue as any }
|
||||
}
|
||||
|
||||
async function generateWidgetEntrypoint(
|
||||
sources: Set<string>,
|
||||
zone: InjectionZone
|
||||
) {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) => crawl(`${source}/widgets`))
|
||||
)
|
||||
).flat()
|
||||
|
||||
const validatedWidgets = (
|
||||
await Promise.all(
|
||||
files.map(async (widget) => {
|
||||
const { valid } = await validateWidget(widget, zone)
|
||||
return valid ? widget : null
|
||||
})
|
||||
)
|
||||
).filter(Boolean) as string[]
|
||||
|
||||
if (!validatedWidgets.length) {
|
||||
const code = `export default {
|
||||
widgets: [],
|
||||
}`
|
||||
|
||||
return { module: generateModule(code), paths: [] }
|
||||
}
|
||||
|
||||
const importString = validatedWidgets
|
||||
.map(
|
||||
(path, index) =>
|
||||
`import WidgetExt${index} from "${convertToImportPath(path)}";`
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
const exportString = `export default {
|
||||
widgets: [${validatedWidgets
|
||||
.map((_, index) => `{ Component: WidgetExt${index} }`)
|
||||
.join(", ")}],
|
||||
}`
|
||||
|
||||
const code = `${importString}\n${exportString}`
|
||||
|
||||
return { module: generateModule(code), paths: validatedWidgets }
|
||||
}
|
||||
|
||||
/** Route utilities */
|
||||
|
||||
function validateRouteConfig(
|
||||
path: NodePath<ExportNamedDeclaration>,
|
||||
resolveMenuItem: boolean
|
||||
) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
|
||||
/**
|
||||
* When resolving links for the sidebar, we a config to get the props needed to
|
||||
* render the link correctly.
|
||||
*
|
||||
* If the user has not provided any config, then the route can never be a valid
|
||||
* menu item, so we can skip the validation, and return false.
|
||||
*/
|
||||
if (!properties && resolveMenuItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A config is not required for a component to be a valid route.
|
||||
*/
|
||||
if (!properties) {
|
||||
return true
|
||||
}
|
||||
|
||||
const labelProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "label"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const labelIsValid =
|
||||
!labelProperty || labelProperty.value.type === "StringLiteral"
|
||||
|
||||
return labelIsValid
|
||||
}
|
||||
|
||||
async function validateRoute(file: string, resolveMenuItem = false) {
|
||||
const content = await fs.readFile(file, "utf-8")
|
||||
const parserOptions = getParserOptions(file)
|
||||
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, parserOptions)
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasDefaultExport = false
|
||||
let hasNamedExport = resolveMenuItem ? false : true
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
hasDefaultExport = isDefaultExportComponent(path, ast)
|
||||
},
|
||||
ExportNamedDeclaration(path) {
|
||||
hasNamedExport = validateRouteConfig(path, resolveMenuItem)
|
||||
},
|
||||
})
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasNamedExport && hasDefaultExport
|
||||
}
|
||||
|
||||
function createRoutePath(file: string) {
|
||||
const importPath = convertToImportPath(file)
|
||||
|
||||
return importPath
|
||||
.replace(/.*\/admin\/(routes|settings)/, "")
|
||||
.replace(/\[([^\]]+)\]/g, ":$1")
|
||||
.replace(/\/page\.(tsx|jsx)/, "")
|
||||
}
|
||||
|
||||
async function generateRouteEntrypoint(
|
||||
sources: Set<string>,
|
||||
type: "page" | "link"
|
||||
) {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
|
||||
const validatedRoutes = (
|
||||
await Promise.all(
|
||||
files.map(async (route) => {
|
||||
const valid = await validateRoute(route, type === "link")
|
||||
return valid ? route : null
|
||||
})
|
||||
)
|
||||
).filter(Boolean) as string[]
|
||||
|
||||
if (!validatedRoutes.length) {
|
||||
const code = `export default {
|
||||
${type}s: [],
|
||||
}`
|
||||
|
||||
return { module: generateModule(code), paths: [] }
|
||||
}
|
||||
|
||||
const importString = validatedRoutes
|
||||
.map((path, index) => {
|
||||
return type === "page"
|
||||
? `import RouteExt${index} from "${convertToImportPath(path)}";`
|
||||
: `import { config as routeConfig${index} } from "${convertToImportPath(
|
||||
path
|
||||
)}";`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const exportString = `export default {
|
||||
${type}s: [${validatedRoutes
|
||||
.map((file, index) => {
|
||||
return type === "page"
|
||||
? `{ path: "${createRoutePath(file)}", Component: RouteExt${index} }`
|
||||
: `{ path: "${createRoutePath(file)}", ...routeConfig${index} }`
|
||||
})
|
||||
.join(", ")}],
|
||||
}`
|
||||
|
||||
const code = `${importString}\n${exportString}`
|
||||
|
||||
return { module: generateModule(code), paths: validatedRoutes }
|
||||
}
|
||||
|
||||
type LoadModuleOptions =
|
||||
| {
|
||||
type: "widget"
|
||||
get: InjectionZone
|
||||
}
|
||||
| {
|
||||
type: "route"
|
||||
get: "page" | "link"
|
||||
}
|
||||
|
||||
export type MedusaVitePluginOptions = {
|
||||
/**
|
||||
* A list of directories to source extensions from.
|
||||
*/
|
||||
sources?: string[]
|
||||
}
|
||||
|
||||
export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin
|
||||
export const medusaVitePlugin: MedusaVitePlugin = (options) => {
|
||||
const _extensionGraph = new Map<string, Set<string>>()
|
||||
const _sources = new Set<string>(options?.sources ?? [])
|
||||
|
||||
let server: Vite.ViteDevServer | undefined
|
||||
let watcher: Vite.FSWatcher | undefined
|
||||
|
||||
async function loadModule(options: LoadModuleOptions) {
|
||||
switch (options.type) {
|
||||
case "widget": {
|
||||
return await generateWidgetEntrypoint(_sources, options.get)
|
||||
}
|
||||
case "route":
|
||||
return await generateRouteEntrypoint(_sources, options.get)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function register(id: string, options: LoadModuleOptions) {
|
||||
const result = await loadModule(options)
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
const { module, paths } = result
|
||||
|
||||
for (const path of paths) {
|
||||
const ids = _extensionGraph.get(path) || new Set<string>()
|
||||
ids.add(id)
|
||||
_extensionGraph.set(path, ids)
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
|
||||
async function handleWidgetChange(file: string, event: "add" | "change") {
|
||||
const { valid, zone } = await validateWidget(file)
|
||||
const zoneValues = Array.isArray(zone) ? zone : [zone]
|
||||
|
||||
if (event === "change") {
|
||||
/**
|
||||
* If the file is in the extension graph, and it has become
|
||||
* invalid, we need to remove it from the graph and reload all modules
|
||||
* that import the widget.
|
||||
*/
|
||||
if (!valid) {
|
||||
const extensionIds = _extensionGraph.get(file)
|
||||
_extensionGraph.delete(file)
|
||||
|
||||
if (!extensionIds) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const moduleId of extensionIds) {
|
||||
const module = server?.moduleGraph.getModuleById(moduleId)
|
||||
|
||||
if (module) {
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If the file is not in the extension graph, we need to add it.
|
||||
* We also need to reload all modules that import the widget.
|
||||
*/
|
||||
if (!_extensionGraph.has(file)) {
|
||||
const imports = new Set<string>()
|
||||
|
||||
for (const zoneValue of zoneValues) {
|
||||
const zonePath = getWidgetImport(zoneValue)
|
||||
const moduleId = getVirtualId(zonePath)
|
||||
const resolvedModuleId = resolveVirtualId(moduleId)
|
||||
const module = server?.moduleGraph.getModuleById(resolvedModuleId)
|
||||
if (module) {
|
||||
imports.add(resolvedModuleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
_extensionGraph.set(file, imports)
|
||||
}
|
||||
|
||||
if (_extensionGraph.has(file)) {
|
||||
const modules = _extensionGraph.get(file)
|
||||
|
||||
if (!modules) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const moduleId of modules) {
|
||||
const module = server?.moduleGraph.getModuleById(moduleId)
|
||||
|
||||
if (!module || !module.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedInjectionZone = getWidgetZone(module.id)
|
||||
|
||||
/**
|
||||
* If the widget is imported in a module that does not match the new
|
||||
* zone value, we need to reload the module, so the widget will be removed.
|
||||
*/
|
||||
if (!zoneValues.includes(matchedInjectionZone)) {
|
||||
modules.delete(moduleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
const imports = new Set<string>(modules)
|
||||
|
||||
/**
|
||||
* If the widget is not currently being imported by the virtual module that
|
||||
* matches its zone value, we need to reload the module, so the widget will be added.
|
||||
*/
|
||||
for (const zoneValue of zoneValues) {
|
||||
const zonePath = getWidgetImport(zoneValue)
|
||||
const moduleId = getVirtualId(zonePath)
|
||||
const resolvedModuleId = resolveVirtualId(moduleId)
|
||||
|
||||
if (!modules.has(resolvedModuleId)) {
|
||||
const module = server?.moduleGraph.getModuleById(resolvedModuleId)
|
||||
if (module) {
|
||||
imports.add(resolvedModuleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_extensionGraph.set(file, imports)
|
||||
}
|
||||
}
|
||||
|
||||
if (event === "add") {
|
||||
/**
|
||||
* If a new file is added in /admin/widgets, but it is not valid,
|
||||
* we don't need to do anything.
|
||||
*/
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If a new file is added in /admin/widgets, and it is valid, we need to
|
||||
* add it to the extension graph and reload all modules that need to import
|
||||
* the widget so that they can be updated with the new widget.
|
||||
*/
|
||||
const imports = new Set<string>()
|
||||
|
||||
for (const zoneValue of zoneValues) {
|
||||
const zonePath = getWidgetImport(zoneValue)
|
||||
const moduleId = getVirtualId(zonePath)
|
||||
const resolvedModuleId = resolveVirtualId(moduleId)
|
||||
|
||||
const module = server?.moduleGraph.getModuleById(resolvedModuleId)
|
||||
|
||||
if (module) {
|
||||
imports.add(resolvedModuleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
_extensionGraph.set(file, imports)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRouteChange(file: string, event: "add" | "change") {
|
||||
const valid = await validateRoute(file)
|
||||
|
||||
if (event === "change") {
|
||||
/**
|
||||
* If the file is in the extension graph, and it has become
|
||||
* invalid, we need to remove it from the graph and reload all modules
|
||||
* that import the route.
|
||||
*/
|
||||
if (!valid) {
|
||||
const extensionIds = _extensionGraph.get(file)
|
||||
_extensionGraph.delete(file)
|
||||
|
||||
if (!extensionIds) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const moduleId of extensionIds) {
|
||||
const module = server?.moduleGraph.getModuleById(moduleId)
|
||||
|
||||
if (module) {
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If the file is not in the extension graph, we need to add it.
|
||||
* We also need to reload all modules that import the route.
|
||||
*/
|
||||
if (!_extensionGraph.has(file)) {
|
||||
const imports = new Set<string>()
|
||||
|
||||
for (const resolvedModuleId of RESOLVED_ROUTE_MODULES) {
|
||||
const module = server?.moduleGraph.getModuleById(resolvedModuleId)
|
||||
if (module) {
|
||||
imports.add(resolvedModuleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
_extensionGraph.set(file, imports)
|
||||
}
|
||||
}
|
||||
|
||||
if (event === "add") {
|
||||
/**
|
||||
* If a new file is added in /admin/routes, but it is not valid,
|
||||
* we don't need to do anything.
|
||||
*/
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
const imports = new Set<string>()
|
||||
|
||||
for (const resolvedModuleId of RESOLVED_ROUTE_MODULES) {
|
||||
const module = server?.moduleGraph.getModuleById(resolvedModuleId)
|
||||
if (module) {
|
||||
imports.add(resolvedModuleId)
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
|
||||
_extensionGraph.set(file, imports)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddOrChange(path: string, event: "add" | "change") {
|
||||
const type = getModuleType(path)
|
||||
|
||||
switch (type) {
|
||||
case "widget":
|
||||
await handleWidgetChange(path, event)
|
||||
break
|
||||
case "route":
|
||||
await handleRouteChange(path, event)
|
||||
break
|
||||
default:
|
||||
// In all other cases we don't need to do anything.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlink(path: string) {
|
||||
const moduleIds = _extensionGraph.get(path)
|
||||
_extensionGraph.delete(path)
|
||||
|
||||
if (!moduleIds) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const moduleId of moduleIds) {
|
||||
const module = server?.moduleGraph.getModuleById(moduleId)
|
||||
|
||||
if (module) {
|
||||
await server?.reloadModule(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "@medusajs/admin-vite-plugin",
|
||||
enforce: "pre",
|
||||
configureServer(_server) {
|
||||
server = _server
|
||||
watcher = _server.watcher
|
||||
|
||||
_sources.forEach((source) => {
|
||||
watcher?.add(source)
|
||||
})
|
||||
|
||||
watcher.on("all", async (event, path) => {
|
||||
switch (event) {
|
||||
case "add":
|
||||
case "change": {
|
||||
await handleAddOrChange(path, event)
|
||||
break
|
||||
}
|
||||
case "unlinkDir":
|
||||
case "unlink":
|
||||
await handleUnlink(path)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
},
|
||||
resolveId(id) {
|
||||
if (VIRTUAL_MODULES.includes(id)) {
|
||||
return resolveVirtualId(id)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
async load(id) {
|
||||
if (RESOLVED_WIDGET_MODULES.includes(id)) {
|
||||
const zone = getWidgetZone(id)
|
||||
return register(id, { type: "widget", get: zone })
|
||||
}
|
||||
|
||||
if (RESOLVED_ROUTE_MODULES.includes(id)) {
|
||||
const type = id.includes("link") ? "link" : "page"
|
||||
return register(id, { type: "route", get: type })
|
||||
}
|
||||
},
|
||||
async closeBundle() {
|
||||
if (watcher) {
|
||||
await watcher.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"declarationMap": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@babel/types": ["../../../node_modules/@babel/types"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["./src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Medusa Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@medusajs/dashboard",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"generate:static": "node ./scripts/generate-currencies.js && prettier --write ./src/lib/currencies.ts",
|
||||
"dev": "vite",
|
||||
"build": "tsup && node ./scripts/generate-types.js",
|
||||
"build:preview": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"main": "dist/app.js",
|
||||
"module": "dist/app.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/app.mjs",
|
||||
"require": "./dist/app.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./css": {
|
||||
"import": "./dist/app.css",
|
||||
"require": "./dist/app.css"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/admin/dashboard"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@hookform/error-message": "^2.0.1",
|
||||
"@hookform/resolvers": "3.4.2",
|
||||
"@medusajs/icons": "1.2.1",
|
||||
"@medusajs/js-sdk": "0.0.1",
|
||||
"@medusajs/ui": "3.0.0",
|
||||
"@radix-ui/react-collapsible": "1.1.0",
|
||||
"@tanstack/react-query": "^5.28.14",
|
||||
"@tanstack/react-table": "8.20.5",
|
||||
"@tanstack/react-virtual": "^3.8.3",
|
||||
"@uiw/react-json-view": "^2.0.0-alpha.17",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.0.3",
|
||||
"i18next": "23.7.11",
|
||||
"i18next-browser-languagedetector": "7.2.0",
|
||||
"i18next-http-backend": "2.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"match-sorter": "^6.3.4",
|
||||
"qs": "^6.12.0",
|
||||
"react": "^18.2.0",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-currency-input-field": "^3.6.11",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "7.49.1",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-router-dom": "6.20.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/admin-shared": "^0.0.1",
|
||||
"@medusajs/admin-vite-plugin": "0.0.1",
|
||||
"@medusajs/types": "1.11.16",
|
||||
"@medusajs/ui-preset": "1.1.3",
|
||||
"@types/node": "^20.11.15",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "^3.1.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,46 @@
|
||||
async function generateCurrencies() {
|
||||
const { currencies } = await import(
|
||||
"@medusajs/medusa/dist/utils/currencies.js"
|
||||
)
|
||||
const fs = await import("fs")
|
||||
const path = await import("path")
|
||||
|
||||
const record = Object.entries(currencies).reduce((acc, [key, values]) => {
|
||||
const code = values.code
|
||||
const symbol_native = values.symbol_native
|
||||
const name = values.name
|
||||
const decimal_digits = values.decimal_digits
|
||||
|
||||
acc[key] = {
|
||||
code,
|
||||
name,
|
||||
symbol_native,
|
||||
decimal_digits,
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const json = JSON.stringify(record, null, 2)
|
||||
|
||||
const dest = path.join(__dirname, "../src/lib/currencies.ts")
|
||||
const destDir = path.dirname(dest)
|
||||
|
||||
const fileContent = `/** This file is auto-generated. Do not modify it manually. */\ntype CurrencyInfo = { code: string; name: string; symbol_native: string; decimal_digits: number }\n\nexport const currencies: Record<string, CurrencyInfo> = ${json}`
|
||||
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(dest, fileContent)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log("Generating currency info")
|
||||
try {
|
||||
await generateCurrencies()
|
||||
console.log("Currency info generated")
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* We can't use the `tsc` command to generate types for the project because it
|
||||
* will generate types for each file in the project, which isn't needed. We only
|
||||
* need a single file that exports the App component.
|
||||
*/
|
||||
async function generateTypes() {
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const distDir = path.resolve(__dirname, "../dist")
|
||||
const filePath = path.join(distDir, "index.d.ts")
|
||||
|
||||
const fileContent = `
|
||||
import * as react_jsx_runtime from "react/jsx-runtime"
|
||||
|
||||
declare const App: () => react_jsx_runtime.JSX.Element
|
||||
|
||||
export default App
|
||||
`
|
||||
|
||||
// Ensure the dist directory exists
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir)
|
||||
}
|
||||
|
||||
// Write the content to the index.d.ts file
|
||||
fs.writeFileSync(filePath, fileContent.trim(), "utf8")
|
||||
|
||||
console.log(`File created at ${filePath}`)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await generateTypes()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Toaster, TooltipProvider } from "@medusajs/ui"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
import { I18n } from "./components/utilities/i18n"
|
||||
import { queryClient } from "./lib/query-client"
|
||||
import { I18nProvider } from "./providers/i18n-provider"
|
||||
import { RouterProvider } from "./providers/router-provider"
|
||||
import { ThemeProvider } from "./providers/theme-provider"
|
||||
|
||||
import "./index.css"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18n />
|
||||
<TooltipProvider>
|
||||
<I18nProvider>
|
||||
<RouterProvider />
|
||||
</I18nProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
export * from "./protected-route"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { useMe } from "../../../hooks/api/users"
|
||||
import { SearchProvider } from "../../../providers/search-provider"
|
||||
import { SidebarProvider } from "../../../providers/sidebar-provider"
|
||||
|
||||
export const ProtectedRoute = () => {
|
||||
const { user, isLoading } = useMe()
|
||||
const location = useLocation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner className="text-ui-fg-interactive animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SearchProvider>
|
||||
<Outlet />
|
||||
</SearchProvider>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
|
||||
|
||||
import { EllipsisHorizontal } from "@medusajs/icons"
|
||||
import { ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export type Action = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
disabled?: boolean
|
||||
} & (
|
||||
| {
|
||||
to: string
|
||||
onClick?: never
|
||||
}
|
||||
| {
|
||||
onClick: () => void
|
||||
to?: never
|
||||
}
|
||||
)
|
||||
|
||||
export type ActionGroup = {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
type ActionMenuProps = {
|
||||
groups: ActionGroup[]
|
||||
}
|
||||
|
||||
export const ActionMenu = ({ groups }: ActionMenuProps) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small" variant="transparent">
|
||||
<EllipsisHorizontal />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{groups.map((group, index) => {
|
||||
if (!group.actions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLast = index === groups.length - 1
|
||||
|
||||
return (
|
||||
<DropdownMenu.Group key={index}>
|
||||
{group.actions.map((action, index) => {
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
disabled={action.disabled}
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<DropdownMenu.Item
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
asChild
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!isLast && <DropdownMenu.Separator />}
|
||||
</DropdownMenu.Group>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./action-menu"
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Badge, Tooltip, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type BadgeListSummaryProps = {
|
||||
/**
|
||||
* Number of initial items to display
|
||||
* @default 2
|
||||
*/
|
||||
n?: number
|
||||
/**
|
||||
* List of strings to display as abbreviated list
|
||||
*/
|
||||
list: string[]
|
||||
/**
|
||||
* Is the summary displayed inline.
|
||||
* Determines whether the center text is truncated if there is no space in the container
|
||||
*/
|
||||
inline?: boolean
|
||||
/**
|
||||
* Whether the badges should be rounded
|
||||
*/
|
||||
rounded?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const BadgeListSummary = ({
|
||||
list,
|
||||
className,
|
||||
inline,
|
||||
rounded = false,
|
||||
n = 2,
|
||||
}: BadgeListSummaryProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const title = t("general.plusCount", {
|
||||
count: list.length - n,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-subtle txt-compact-small gap-x-2 overflow-hidden",
|
||||
{
|
||||
"inline-flex": inline,
|
||||
flex: !inline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{list.slice(0, n).map((item) => {
|
||||
return (
|
||||
<Badge rounded={rounded ? "full" : "base"} key={item} size="2xsmall">
|
||||
{item}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
|
||||
{list.length > n && (
|
||||
<div className="whitespace-nowrap">
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{list.slice(n).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
rounded={rounded ? "full" : "base"}
|
||||
size="2xsmall"
|
||||
className="cursor-default whitespace-nowrap"
|
||||
>
|
||||
{title}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./badge-list-summary"
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DropdownMenu, clx } from "@medusajs/ui"
|
||||
|
||||
import { PropsWithChildren, ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
type Action = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
disabled?: boolean
|
||||
} & (
|
||||
| {
|
||||
to: string
|
||||
onClick?: never
|
||||
}
|
||||
| {
|
||||
onClick: () => void
|
||||
to?: never
|
||||
}
|
||||
)
|
||||
|
||||
type ActionGroup = {
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
type ActionMenuProps = {
|
||||
groups: ActionGroup[]
|
||||
}
|
||||
|
||||
export const ButtonMenu = ({
|
||||
groups,
|
||||
children,
|
||||
}: PropsWithChildren<ActionMenuProps>) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
{groups.map((group, index) => {
|
||||
if (!group.actions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLast = index === groups.length - 1
|
||||
|
||||
return (
|
||||
<DropdownMenu.Group key={index}>
|
||||
{group.actions.map((action, index) => {
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
disabled={action.disabled}
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<DropdownMenu.Item
|
||||
className={clx(
|
||||
"[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
|
||||
{
|
||||
"[&_svg]:text-ui-fg-disabled": action.disabled,
|
||||
}
|
||||
)}
|
||||
asChild
|
||||
disabled={action.disabled}
|
||||
>
|
||||
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!isLast && <DropdownMenu.Separator />}
|
||||
</DropdownMenu.Group>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./action-menu"
|
||||
@@ -0,0 +1,111 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Button, clx } from "@medusajs/ui"
|
||||
import { Children, PropsWithChildren, createContext, useContext } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ChipGroupVariant = "base" | "component"
|
||||
|
||||
type ChipGroupProps = PropsWithChildren<{
|
||||
onClearAll?: () => void
|
||||
onRemove?: (index: number) => void
|
||||
variant?: ChipGroupVariant
|
||||
className?: string
|
||||
}>
|
||||
|
||||
type GroupContextValue = {
|
||||
onRemove?: (index: number) => void
|
||||
variant: ChipGroupVariant
|
||||
}
|
||||
|
||||
const GroupContext = createContext<GroupContextValue | null>(null)
|
||||
|
||||
const useGroupContext = () => {
|
||||
const context = useContext(GroupContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useGroupContext must be used within a ChipGroup component")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Group = ({
|
||||
onClearAll,
|
||||
onRemove,
|
||||
variant = "component",
|
||||
className,
|
||||
children,
|
||||
}: ChipGroupProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showClearAll = !!onClearAll && Children.count(children) > 0
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{ onRemove, variant }}>
|
||||
<ul
|
||||
role="application"
|
||||
className={clx("flex flex-wrap items-center gap-2", className)}
|
||||
>
|
||||
{children}
|
||||
{showClearAll && (
|
||||
<li>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-ui-fg-muted active:text-ui-fg-subtle"
|
||||
>
|
||||
{t("actions.clearAll")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type ChipProps = PropsWithChildren<{
|
||||
index: number
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const Chip = ({ index, className, children }: ChipProps) => {
|
||||
const { onRemove, variant } = useGroupContext()
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clx(
|
||||
"bg-ui-bg-component shadow-borders-base flex items-center divide-x overflow-hidden rounded-md",
|
||||
{
|
||||
"bg-ui-bg-component": variant === "component",
|
||||
"bg-ui-bg-base-": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="txt-compact-small-plus text-ui-fg-subtle flex items-center justify-center px-2 py-1">
|
||||
{children}
|
||||
</span>
|
||||
{!!onRemove && (
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
type="button"
|
||||
className={clx(
|
||||
"text-ui-fg-muted active:text-ui-fg-subtle transition-fg flex items-center justify-center p-1",
|
||||
{
|
||||
"hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed":
|
||||
variant === "component",
|
||||
"hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed":
|
||||
variant === "base",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChipGroup = Object.assign(Group, { Chip })
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chip-group"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Tooltip } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef, PropsWithChildren } from "react"
|
||||
|
||||
type ConditionalTooltipProps = PropsWithChildren<
|
||||
ComponentPropsWithoutRef<typeof Tooltip> & {
|
||||
showTooltip?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export const ConditionalTooltip = ({
|
||||
children,
|
||||
showTooltip = false,
|
||||
...props
|
||||
}: ConditionalTooltipProps) => {
|
||||
if (showTooltip) {
|
||||
return <Tooltip {...props}>{children}</Tooltip>
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./conditional-tooltip"
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Avatar, Copy, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getFormattedAddress, isSameAddress } from "../../../lib/addresses"
|
||||
|
||||
const ID = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const id = data.customer_id
|
||||
const name = getOrderCustomer(data)
|
||||
const email = data.email
|
||||
const fallback = (name || email || "").charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.id")}
|
||||
</Text>
|
||||
<Link
|
||||
to={`/customers/${id}`}
|
||||
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-x-2 overflow-hidden">
|
||||
<Avatar size="2xsmall" fallback={fallback} />
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
|
||||
>
|
||||
{name || email}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Company = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
const company =
|
||||
data.shipping_address?.company || data.billing_address?.company
|
||||
|
||||
if (!company) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.company")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="truncate">
|
||||
{company}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Contact = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const phone = data.shipping_address?.phone || data.billing_address?.phone
|
||||
const email = data.email || ""
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("orders.customer.contactLabel")}
|
||||
</Text>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
{phone && (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-pretty break-all"
|
||||
>
|
||||
{phone}
|
||||
</Text>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Copy content={email} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AddressPrint = ({
|
||||
address,
|
||||
type,
|
||||
}: {
|
||||
address:
|
||||
| HttpTypes.AdminOrder["shipping_address"]
|
||||
| HttpTypes.AdminOrder["billing_address"]
|
||||
type: "shipping" | "billing"
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{type === "shipping"
|
||||
? t("addresses.shippingAddress.label")
|
||||
: t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
{address ? (
|
||||
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
|
||||
<Text size="small" leading="compact">
|
||||
{getFormattedAddress({ address }).map((line, i) => {
|
||||
return (
|
||||
<span key={i} className="break-words">
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
<div className="flex justify-end">
|
||||
<Copy
|
||||
content={getFormattedAddress({ address }).join("\n")}
|
||||
className="text-ui-fg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="small" leading="compact">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Addresses = ({ data }: { data: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<AddressPrint address={data.shipping_address} type="shipping" />
|
||||
{!isSameAddress(data.shipping_address, data.billing_address) ? (
|
||||
<AddressPrint address={data.billing_address} type="billing" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
{t("addresses.billingAddress.sameAsShipping")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomerInfo = Object.assign(
|
||||
{},
|
||||
{
|
||||
ID,
|
||||
Company,
|
||||
Contact,
|
||||
Addresses,
|
||||
}
|
||||
)
|
||||
|
||||
const getOrderCustomer = (obj: HttpTypes.AdminOrder) => {
|
||||
const { first_name: sFirstName, last_name: sLastName } =
|
||||
obj.shipping_address || {}
|
||||
const { first_name: bFirstName, last_name: bLastName } =
|
||||
obj.billing_address || {}
|
||||
const { first_name: cFirstName, last_name: cLastName } = obj.customer || {}
|
||||
|
||||
const customerName = [cFirstName, cLastName].filter(Boolean).join(" ")
|
||||
const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ")
|
||||
const billingName = [bFirstName, bLastName].filter(Boolean).join(" ")
|
||||
|
||||
const name = customerName || shippingName || billingName
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./customer-info"
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useDate } from "../../../hooks/use-date"
|
||||
|
||||
type DateRangeDisplayProps = {
|
||||
startsAt?: Date | string | null
|
||||
endsAt?: Date | string | null
|
||||
showTime?: boolean
|
||||
}
|
||||
|
||||
export const DateRangeDisplay = ({
|
||||
startsAt,
|
||||
endsAt,
|
||||
showTime = false,
|
||||
}: DateRangeDisplayProps) => {
|
||||
const startDate = startsAt ? new Date(startsAt) : null
|
||||
const endDate = endsAt ? new Date(endsAt) : null
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { getFullDate } = useDate()
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
|
||||
<Bar date={startDate} />
|
||||
<div>
|
||||
<Text weight="plus" size="small">
|
||||
{t("fields.startDate")}
|
||||
</Text>
|
||||
<Text size="small" className="tabular-nums">
|
||||
{startDate
|
||||
? getFullDate({
|
||||
date: startDate,
|
||||
includeTime: showTime,
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component text-ui-fg-subtle flex items-center gap-x-3 rounded-md px-3 py-1.5">
|
||||
<Bar date={endDate} />
|
||||
<div>
|
||||
<Text size="small" weight="plus">
|
||||
{t("fields.endDate")}
|
||||
</Text>
|
||||
<Text size="small" className="tabular-nums">
|
||||
{endDate
|
||||
? getFullDate({
|
||||
date: endDate,
|
||||
includeTime: showTime,
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Bar = ({ date }: { date: Date | null }) => {
|
||||
const now = new Date()
|
||||
|
||||
const isDateInFuture = date && date > now
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("bg-ui-tag-neutral-icon h-8 w-1 rounded-full", {
|
||||
"bg-ui-tag-orange-icon": isDateInFuture,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./date-range-display"
|
||||
@@ -0,0 +1,11 @@
|
||||
import format from "date-fns/format"
|
||||
|
||||
export function formatDate(date: string | Date) {
|
||||
const value = new Date(date)
|
||||
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||
|
||||
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
|
||||
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
|
||||
|
||||
return format(value, timestampFormat)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
interface DividerProps
|
||||
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
variant?: "dashed" | "solid"
|
||||
}
|
||||
|
||||
export const Divider = ({
|
||||
orientation = "horizontal",
|
||||
variant = "solid",
|
||||
className,
|
||||
...props
|
||||
}: DividerProps) => {
|
||||
return (
|
||||
<div
|
||||
aria-orientation={orientation}
|
||||
role="separator"
|
||||
className={clx(
|
||||
"border-ui-border-base",
|
||||
{
|
||||
"w-full border-t":
|
||||
orientation === "horizontal" && variant === "solid",
|
||||
"h-full border-l": orientation === "vertical" && variant === "solid",
|
||||
"bg-transparent": variant === "dashed",
|
||||
"h-px w-full bg-[linear-gradient(90deg,var(--border-strong)_1px,transparent_1px)] bg-[length:4px_1px]":
|
||||
variant === "dashed" && orientation === "horizontal",
|
||||
"h-full w-px bg-[linear-gradient(0deg,var(--border-strong)_1px,transparent_1px)] bg-[length:1px_4px]":
|
||||
variant === "dashed" && orientation === "vertical",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./divider"
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { ExclamationCircle, MagnifyingGlass, PlusMini } from "@medusajs/icons"
|
||||
import { Button, Text, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export type NoResultsProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const NoResults = ({ title, message, className }: NoResultsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex h-[400px] w-full items-center justify-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
<MagnifyingGlass />
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{title ?? t("general.noResultsTitle")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{message ?? t("general.noResultsMessage")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionProps = {
|
||||
action?: {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
type NoRecordsProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
className?: string
|
||||
buttonVariant?: string
|
||||
} & ActionProps
|
||||
|
||||
const DefaultButton = ({ action }: ActionProps) =>
|
||||
action && (
|
||||
<Link to={action.to}>
|
||||
<Button variant="secondary" size="small">
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const TransparentIconLeftButton = ({ action }: ActionProps) =>
|
||||
action && (
|
||||
<Link to={action.to}>
|
||||
<Button variant="transparent" className="text-ui-fg-interactive">
|
||||
<PlusMini /> {action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
export const NoRecords = ({
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
className,
|
||||
buttonVariant = "default",
|
||||
}: NoRecordsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"flex h-[400px] w-full flex-col items-center justify-center gap-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
<ExclamationCircle className="text-ui-fg-subtle" />
|
||||
|
||||
<div className="flex flex-col items-center gap-y-1">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{title ?? t("general.noRecordsTitle")}
|
||||
</Text>
|
||||
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{message ?? t("general.noRecordsMessage")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buttonVariant === "default" && <DefaultButton action={action} />}
|
||||
{buttonVariant === "transparentIconLeft" && (
|
||||
<TransparentIconLeftButton action={action} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./empty-table-content"
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ArrowDownTray, Spinner } from "@medusajs/icons"
|
||||
import { IconButton, Text } from "@medusajs/ui"
|
||||
import { ActionGroup, ActionMenu } from "../action-menu"
|
||||
|
||||
export const FilePreview = ({
|
||||
filename,
|
||||
url,
|
||||
loading,
|
||||
activity,
|
||||
actions,
|
||||
hideThumbnail,
|
||||
}: {
|
||||
filename: string
|
||||
url?: string
|
||||
loading?: boolean
|
||||
activity?: string
|
||||
actions?: ActionGroup[]
|
||||
hideThumbnail?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg rounded-md px-3 py-2">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{!hideThumbnail && <FileThumbnail />}
|
||||
<div className="flex flex-col justify-center">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="truncate max-w-[260px]"
|
||||
>
|
||||
{filename}
|
||||
</Text>
|
||||
|
||||
{loading && !!activity && (
|
||||
<Text
|
||||
leading="compact"
|
||||
size="xsmall"
|
||||
className="text-ui-fg-interactive"
|
||||
>
|
||||
{activity}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <Spinner className="animate-spin" />}
|
||||
{!loading && actions && <ActionMenu groups={actions} />}
|
||||
{!loading && url && (
|
||||
<IconButton variant="transparent" asChild>
|
||||
<a href={url} download={filename ?? `${Date.now()}`}>
|
||||
<ArrowDownTray />
|
||||
</a>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileThumbnail = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="32"
|
||||
viewBox="0 0 24 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 31.75H4C1.92893 31.75 0.25 30.0711 0.25 28V4C0.25 1.92893 1.92893 0.25 4 0.25H15.9431C16.9377 0.25 17.8915 0.645088 18.5948 1.34835L22.6516 5.4052C23.3549 6.10847 23.75 7.06229 23.75 8.05685V28C23.75 30.0711 22.0711 31.75 20 31.75Z"
|
||||
fill="url(#paint0_linear_6594_388107)"
|
||||
stroke="url(#paint1_linear_6594_388107)"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M17.7857 12.8125V13.5357H10.3393V10.9643H15.9375C16.9569 10.9643 17.7857 11.7931 17.7857 12.8125ZM6.21429 16.9107V15.0893H8.78571V16.9107H6.21429ZM10.3393 16.9107V15.0893H17.7857V16.9107H10.3393ZM15.9375 21.0357H10.3393V18.4643H17.7857V19.1875C17.7857 20.2069 16.9569 21.0357 15.9375 21.0357ZM6.21429 19.1875V18.4643H8.78571V21.0357H8.0625C7.0431 21.0357 6.21429 20.2069 6.21429 19.1875ZM8.0625 10.9643H8.78571V13.5357H6.21429V12.8125C6.21429 11.7931 7.0431 10.9643 8.0625 10.9643Z"
|
||||
fill="url(#paint2_linear_6594_388107)"
|
||||
stroke="url(#paint3_linear_6594_388107)"
|
||||
stroke-width="0.428571"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="0"
|
||||
x2="12"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F4F4F5" />
|
||||
<stop offset="1" stop-color="#E4E4E7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="0"
|
||||
x2="12"
|
||||
y2="32"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#E4E4E7" />
|
||||
<stop offset="1" stop-color="#D4D4D8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="10.75"
|
||||
x2="12"
|
||||
y2="21.25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#52525B" />
|
||||
<stop offset="1" stop-color="#A1A1AA" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_6594_388107"
|
||||
x1="12"
|
||||
y1="10.75"
|
||||
x2="12"
|
||||
y2="21.25"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#18181B" />
|
||||
<stop offset="1" stop-color="#52525B" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./file-preview"
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ArrowDownTray } from "@medusajs/icons"
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
import { ChangeEvent, DragEvent, useRef, useState } from "react"
|
||||
|
||||
export interface FileType {
|
||||
id: string
|
||||
url: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
label: string
|
||||
multiple?: boolean
|
||||
hint?: string
|
||||
hasError?: boolean
|
||||
formats: string[]
|
||||
onUploaded: (files: FileType[]) => void
|
||||
}
|
||||
|
||||
export const FileUpload = ({
|
||||
label,
|
||||
hint,
|
||||
multiple = true,
|
||||
hasError,
|
||||
formats,
|
||||
onUploaded,
|
||||
}: FileUploadProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleOpenFileSelector = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (
|
||||
!dropZoneRef.current ||
|
||||
dropZoneRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragOver(false)
|
||||
}
|
||||
|
||||
const handleUploaded = (files: FileList | null) => {
|
||||
if (!files) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileList = Array.from(files)
|
||||
const fileObj = fileList.map((file) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
return {
|
||||
id: id,
|
||||
url: previewUrl,
|
||||
file,
|
||||
}
|
||||
})
|
||||
|
||||
onUploaded(fileObj)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDragOver(false)
|
||||
|
||||
handleUploaded(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleUploaded(event.target.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={handleOpenFileSelector}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={clx(
|
||||
"bg-ui-bg-component border-ui-border-strong transition-fg group flex w-full flex-col items-center gap-y-2 rounded-lg border border-dashed p-8",
|
||||
"hover:border-ui-border-interactive focus:border-ui-border-interactive",
|
||||
"focus:shadow-borders-focus outline-none focus:border-solid",
|
||||
{
|
||||
"!border-ui-border-error": hasError,
|
||||
"!border-ui-border-interactive": isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="text-ui-fg-subtle group-disabled:text-ui-fg-disabled flex items-center gap-x-2">
|
||||
<ArrowDownTray />
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
{!!hint && (
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted group-disabled:text-ui-fg-disabled"
|
||||
>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
hidden
|
||||
ref={inputRef}
|
||||
onChange={handleFileChange}
|
||||
type="file"
|
||||
accept={formats.join(",")}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./file-upload"
|
||||
@@ -0,0 +1,223 @@
|
||||
import { InformationCircleSolid } from "@medusajs/icons"
|
||||
import {
|
||||
Hint as HintComponent,
|
||||
Label as LabelComponent,
|
||||
Text,
|
||||
Tooltip,
|
||||
clx,
|
||||
} from "@medusajs/ui"
|
||||
import * as LabelPrimitives from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useId,
|
||||
} from "react"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const Provider = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const Field = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = useContext(FormFieldContext)
|
||||
const itemContext = useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within a FormField")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formLabelId: `${id}-form-item-label`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formErrorMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx("flex flex-col space-y-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Item.displayName = "Form.Item"
|
||||
|
||||
const Label = forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
|
||||
optional?: boolean
|
||||
tooltip?: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
>(({ className, optional = false, tooltip, icon, ...props }, ref) => {
|
||||
const { formLabelId, formItemId } = useFormField()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LabelComponent
|
||||
id={formLabelId}
|
||||
ref={ref}
|
||||
className={clx(className)}
|
||||
htmlFor={formItemId}
|
||||
size="small"
|
||||
weight="plus"
|
||||
{...props}
|
||||
/>
|
||||
{tooltip && (
|
||||
<Tooltip content={tooltip}>
|
||||
<InformationCircleSolid className="text-ui-fg-muted" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{icon}
|
||||
{optional && (
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
({t("fields.optional")})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Label.displayName = "Form.Label"
|
||||
|
||||
const Control = forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const {
|
||||
error,
|
||||
formItemId,
|
||||
formDescriptionId,
|
||||
formErrorMessageId,
|
||||
formLabelId,
|
||||
} = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formErrorMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
aria-labelledby={formLabelId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Control.displayName = "Form.Control"
|
||||
|
||||
const Hint = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Hint.displayName = "Form.Hint"
|
||||
|
||||
const ErrorMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formErrorMessageId } = useFormField()
|
||||
const msg = error ? String(error?.message) : children
|
||||
|
||||
if (!msg || msg === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formErrorMessageId}
|
||||
className={className}
|
||||
variant={error ? "error" : "info"}
|
||||
{...props}
|
||||
>
|
||||
{msg}
|
||||
</HintComponent>
|
||||
)
|
||||
})
|
||||
ErrorMessage.displayName = "Form.ErrorMessage"
|
||||
|
||||
const Form = Object.assign(Provider, {
|
||||
Item,
|
||||
Label,
|
||||
Control,
|
||||
Hint,
|
||||
ErrorMessage,
|
||||
Field,
|
||||
})
|
||||
|
||||
export { Form }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./form";
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { ReactNode, Ref, RefAttributes, forwardRef } from "react"
|
||||
|
||||
export function genericForwardRef<T, P = {}>(
|
||||
render: (props: P, ref: Ref<T>) => ReactNode
|
||||
): (props: P & RefAttributes<T>) => ReactNode {
|
||||
return forwardRef(render) as any
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generic-forward-ref"
|
||||
@@ -0,0 +1,38 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
type IconAvatarProps = PropsWithChildren<{
|
||||
className?: string
|
||||
size?: "small" | "large" | "xlarge"
|
||||
}>
|
||||
|
||||
/**
|
||||
* Use this component when a design calls for an avatar with an icon.
|
||||
*
|
||||
* The `<Avatar/>` component from `@medusajs/ui` does not support passing an icon as a child.
|
||||
*/
|
||||
export const IconAvatar = ({
|
||||
size = "small",
|
||||
children,
|
||||
className,
|
||||
}: IconAvatarProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-borders-base flex size-7 items-center justify-center",
|
||||
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
|
||||
{
|
||||
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
|
||||
size === "small",
|
||||
"size-10 rounded-lg [&>div]:size-9 [&>div]:rounded-[6px]":
|
||||
size === "large",
|
||||
"size-12 rounded-xl [&>div]:size-11 [&>div]:rounded-[10px]":
|
||||
size === "xlarge",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./icon-avatar"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./infinite-list"
|
||||
@@ -0,0 +1,135 @@
|
||||
import { QueryKey, useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { ReactNode, useEffect, useMemo, useRef } from "react"
|
||||
import { toast } from "@medusajs/ui"
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
|
||||
type InfiniteListProps<TResponse, TEntity, TParams> = {
|
||||
queryKey: QueryKey
|
||||
queryFn: (params: TParams) => Promise<TResponse>
|
||||
queryOptions?: { enabled?: boolean }
|
||||
renderItem: (item: TEntity) => ReactNode
|
||||
renderEmpty: () => ReactNode
|
||||
responseKey: keyof TResponse
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const InfiniteList = <
|
||||
TResponse extends { count: number; offset: number; limit: number },
|
||||
TEntity extends { id: string },
|
||||
TParams extends { offset?: number; limit?: number },
|
||||
>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
queryOptions,
|
||||
renderItem,
|
||||
renderEmpty,
|
||||
responseKey,
|
||||
pageSize = 20,
|
||||
}: InfiniteListProps<TResponse, TEntity, TParams>) => {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isPending,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await queryFn({
|
||||
limit: pageSize,
|
||||
offset: pageParam,
|
||||
} as TParams)
|
||||
},
|
||||
initialPageParam: 0,
|
||||
maxPages: 5,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
getPreviousPageParam: (firstPage) => {
|
||||
const moreItemsExist = firstPage.offset !== 0
|
||||
return moreItemsExist
|
||||
? Math.max(firstPage.offset - firstPage.limit, 0)
|
||||
: undefined
|
||||
},
|
||||
...queryOptions,
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
return data?.pages.flatMap((p) => p[responseKey] as TEntity[]) ?? []
|
||||
}, [data, responseKey])
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const startObserver = useRef<IntersectionObserver>()
|
||||
const endObserver = useRef<IntersectionObserver>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
// Define the new observers after we stop fetching
|
||||
if (!isFetching) {
|
||||
// Define the new observers after paginating
|
||||
startObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasPreviousPage) {
|
||||
fetchPreviousPage()
|
||||
}
|
||||
})
|
||||
|
||||
endObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Register the new observers to observe the new first and last children
|
||||
startObserver.current?.observe(parentRef.current!.firstChild as Element)
|
||||
endObserver.current?.observe(parentRef.current!.lastChild as Element)
|
||||
}
|
||||
|
||||
// Clear the old observers
|
||||
return () => {
|
||||
startObserver.current?.disconnect()
|
||||
endObserver.current?.disconnect()
|
||||
}
|
||||
}, [
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetching,
|
||||
isPending,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Spinner className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full">
|
||||
{items?.length
|
||||
? items.map((item) => <div key={item.id}>{renderItem(item)}</div>)
|
||||
: renderEmpty()}
|
||||
|
||||
{isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<Spinner className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./inline-tip"
|
||||
@@ -0,0 +1,60 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef, forwardRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface InlineTipProps extends ComponentPropsWithoutRef<"div"> {
|
||||
/**
|
||||
* The label to display in the tip.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* The variant of the tip.
|
||||
*/
|
||||
variant?: "tip" | "warning"
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for rendering inline tips. Useful for providing additional information or context.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <InlineTip label="Info">
|
||||
* This is an info tip.
|
||||
* </InlineTip>
|
||||
* ```
|
||||
*
|
||||
* TODO: Move to `@medusajs/ui` package.
|
||||
*/
|
||||
export const InlineTip = forwardRef<HTMLDivElement, InlineTipProps>(
|
||||
({ variant = "tip", label, className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const labelValue =
|
||||
label || (variant === "warning" ? t("general.warning") : t("general.tip"))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-ui-bg-component txt-small text-ui-fg-subtle grid grid-cols-[4px_1fr] items-start gap-3 rounded-lg border p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className={clx("w-4px bg-ui-tag-neutral-icon h-full rounded-full", {
|
||||
"bg-ui-tag-orange-icon": variant === "warning",
|
||||
})}
|
||||
/>
|
||||
<div className="text-pretty">
|
||||
<strong className="txt-small-plus text-ui-fg-base">
|
||||
{labelValue}:
|
||||
</strong>{" "}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
InlineTip.displayName = "InlineTip"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./json-view-section"
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
ArrowUpRightOnBox,
|
||||
Check,
|
||||
SquareTwoStack,
|
||||
TriangleDownMini,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Drawer,
|
||||
Heading,
|
||||
IconButton,
|
||||
Kbd,
|
||||
} from "@medusajs/ui"
|
||||
import Primitive from "@uiw/react-json-view"
|
||||
import { CSSProperties, MouseEvent, Suspense, useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
type JsonViewSectionProps = {
|
||||
data: object
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const JsonViewSection = ({ data }: JsonViewSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const numberOfKeys = Object.keys(data).length
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Heading level="h2">{t("json.header")}</Heading>
|
||||
<Badge size="2xsmall" rounded="full">
|
||||
{t("json.numberOfKeys", {
|
||||
count: numberOfKeys,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<Drawer>
|
||||
<Drawer.Trigger asChild>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<ArrowUpRightOnBox />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content className="bg-ui-contrast-bg-base text-ui-code-fg-subtle !shadow-elevation-commandbar overflow-hidden border border-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
|
||||
<div className="bg-ui-code-bg-base flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Drawer.Title asChild>
|
||||
<Heading className="text-ui-contrast-fg-primary">
|
||||
<Trans
|
||||
i18nKey="json.drawer.header"
|
||||
count={numberOfKeys}
|
||||
components={[
|
||||
<span key="count-span" className="text-ui-fg-subtle" />,
|
||||
]}
|
||||
/>
|
||||
</Heading>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">
|
||||
{t("json.drawer.description")}
|
||||
</Drawer.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Kbd className="bg-ui-contrast-bg-subtle border-ui-contrast-border-base text-ui-contrast-fg-secondary">
|
||||
esc
|
||||
</Kbd>
|
||||
<Drawer.Close asChild>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
className="text-ui-contrast-fg-secondary hover:text-ui-contrast-fg-primary hover:bg-ui-contrast-bg-base-hover active:bg-ui-contrast-bg-base-pressed focus-visible:bg-ui-contrast-bg-base-hover focus-visible:shadow-borders-interactive-with-active"
|
||||
>
|
||||
<XMarkMini />
|
||||
</IconButton>
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer.Body className="flex flex-1 flex-col overflow-hidden px-[5px] py-0 pb-[5px]">
|
||||
<div className="bg-ui-contrast-bg-subtle flex-1 overflow-auto rounded-b-[4px] rounded-t-lg p-3">
|
||||
<Suspense
|
||||
fallback={<div className="flex size-full flex-col"></div>}
|
||||
>
|
||||
<Primitive
|
||||
value={data}
|
||||
displayDataTypes={false}
|
||||
style={
|
||||
{
|
||||
"--w-rjv-font-family": "Roboto Mono, monospace",
|
||||
"--w-rjv-line-color": "var(--contrast-border-base)",
|
||||
"--w-rjv-curlybraces-color":
|
||||
"var(--contrast-fg-secondary)",
|
||||
"--w-rjv-brackets-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-key-string": "var(--contrast-fg-primary)",
|
||||
"--w-rjv-info-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-type-string-color": "var(--tag-green-icon)",
|
||||
"--w-rjv-quotes-string-color": "var(--tag-green-icon)",
|
||||
"--w-rjv-type-boolean-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-int-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-float-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-type-bigint-color": "var(--tag-orange-icon)",
|
||||
"--w-rjv-key-number": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-arrow-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-copied-color": "var(--contrast-fg-secondary)",
|
||||
"--w-rjv-copied-success-color":
|
||||
"var(--contrast-fg-primary)",
|
||||
"--w-rjv-colon-color": "var(--contrast-fg-primary)",
|
||||
"--w-rjv-ellipsis-color": "var(--contrast-fg-secondary)",
|
||||
} as CSSProperties
|
||||
}
|
||||
collapsed={1}
|
||||
>
|
||||
<Primitive.Quote render={() => <span />} />
|
||||
<Primitive.Null
|
||||
render={() => (
|
||||
<span className="text-ui-tag-red-icon">null</span>
|
||||
)}
|
||||
/>
|
||||
<Primitive.Undefined
|
||||
render={() => (
|
||||
<span className="text-ui-tag-blue-icon">undefined</span>
|
||||
)}
|
||||
/>
|
||||
<Primitive.CountInfo
|
||||
render={(_props, { value }) => {
|
||||
return (
|
||||
<span className="text-ui-contrast-fg-secondary ml-2">
|
||||
{t("general.items", {
|
||||
count: Object.keys(value as object).length,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Primitive.Arrow>
|
||||
<TriangleDownMini className="text-ui-contrast-fg-secondary -ml-[0.5px]" />
|
||||
</Primitive.Arrow>
|
||||
<Primitive.Colon>
|
||||
<span className="mr-1">:</span>
|
||||
</Primitive.Colon>
|
||||
<Primitive.Copied
|
||||
render={({ style }, { value }) => {
|
||||
return <Copied style={style} value={value} />
|
||||
}}
|
||||
/>
|
||||
</Primitive>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
type CopiedProps = {
|
||||
style?: CSSProperties
|
||||
value: object | undefined
|
||||
}
|
||||
|
||||
const Copied = ({ style, value }: CopiedProps) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handler = (e: MouseEvent<HTMLSpanElement>) => {
|
||||
e.stopPropagation()
|
||||
setCopied(true)
|
||||
|
||||
if (typeof value === "string") {
|
||||
navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const json = JSON.stringify(value, null, 2)
|
||||
navigator.clipboard.writeText(json)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const styl = { whiteSpace: "nowrap", width: "20px" }
|
||||
|
||||
if (copied) {
|
||||
return (
|
||||
<span style={{ ...style, ...styl }}>
|
||||
<Check className="text-ui-contrast-fg-primary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ ...style, ...styl }} onClick={handler}>
|
||||
<SquareTwoStack className="text-ui-contrast-fg-secondary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./link-button"
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
|
||||
variant?: "primary" | "interactive"
|
||||
}
|
||||
|
||||
export const LinkButton = ({
|
||||
className,
|
||||
variant = "interactive",
|
||||
...props
|
||||
}: LinkButtonProps) => {
|
||||
return (
|
||||
<Link
|
||||
className={clx(
|
||||
"transition-fg txt-compact-small-plus rounded-[4px] outline-none",
|
||||
"focus-visible:shadow-borders-focus",
|
||||
{
|
||||
"text-ui-fg-interactive hover:text-ui-fg-interactive-hover":
|
||||
variant === "interactive",
|
||||
"text-ui-fg-base hover:text-ui-fg-subtle": variant === "primary",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ListSummary } from "./list-summary"
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Tooltip, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type ListSummaryProps = {
|
||||
/**
|
||||
* Number of initial items to display
|
||||
* @default 2
|
||||
*/
|
||||
n?: number
|
||||
/**
|
||||
* List of strings to display as abbreviated list
|
||||
*/
|
||||
list: string[]
|
||||
/**
|
||||
* Is the summary displayed inline.
|
||||
* Determines whether the center text is truncated if there is no space in the container
|
||||
*/
|
||||
inline?: boolean
|
||||
variant?: "base" | "compact"
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ListSummary = ({
|
||||
list,
|
||||
className,
|
||||
variant = "compact",
|
||||
inline,
|
||||
n = 2,
|
||||
}: ListSummaryProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const title = t("general.plusCountMore", {
|
||||
count: list.length - n,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"text-ui-fg-subtle gap-x-1 overflow-hidden",
|
||||
{
|
||||
"inline-flex": inline,
|
||||
flex: !inline,
|
||||
"txt-compact-small": variant === "compact",
|
||||
"txt-small": variant === "base",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 truncate">
|
||||
<span className="truncate">{list.slice(0, n).join(", ")}</span>
|
||||
</div>
|
||||
{list.length > n && (
|
||||
<div className="whitespace-nowrap">
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{list.slice(n).map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<span className="cursor-default whitespace-nowrap">{title}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./logo-box"
|
||||
@@ -0,0 +1,74 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { Transition, motion } from "framer-motion"
|
||||
|
||||
type LogoBoxProps = {
|
||||
className?: string
|
||||
checked?: boolean
|
||||
containerTransition?: Transition
|
||||
pathTransition?: Transition
|
||||
}
|
||||
|
||||
export const LogoBox = ({
|
||||
className,
|
||||
checked,
|
||||
containerTransition = {
|
||||
duration: 0.8,
|
||||
delay: 0.5,
|
||||
ease: [0, 0.71, 0.2, 1.01],
|
||||
},
|
||||
pathTransition = {
|
||||
duration: 0.8,
|
||||
delay: 0.6,
|
||||
ease: [0.1, 0.8, 0.2, 1.01],
|
||||
},
|
||||
}: LogoBoxProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"size-14 bg-ui-button-neutral shadow-buttons-neutral relative flex items-center justify-center rounded-xl",
|
||||
"after:button-neutral-gradient after:inset-0 after:content-['']",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<motion.div
|
||||
className="size-5 absolute -right-[5px] -top-1 flex items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={containerTransition}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<motion.path
|
||||
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={pathTransition}
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
<svg
|
||||
width="36"
|
||||
height="38"
|
||||
viewBox="0 0 36 38"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M30.85 6.16832L22.2453 1.21782C19.4299 -0.405941 15.9801 -0.405941 13.1648 1.21782L4.52043 6.16832C1.74473 7.79208 0 10.802 0 14.0099V23.9505C0 27.198 1.74473 30.1683 4.52043 31.7921L13.1251 36.7822C15.9405 38.4059 19.3903 38.4059 22.2056 36.7822L30.8103 31.7921C33.6257 30.1683 35.3307 27.198 35.3307 23.9505V14.0099C35.41 10.802 33.6653 7.79208 30.85 6.16832ZM17.6852 27.8317C12.8079 27.8317 8.8426 23.8713 8.8426 19C8.8426 14.1287 12.8079 10.1683 17.6852 10.1683C22.5625 10.1683 26.5674 14.1287 26.5674 19C26.5674 23.8713 22.6022 27.8317 17.6852 27.8317Z"
|
||||
className="fill-ui-button-inverted relative drop-shadow-sm"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./metadata-section"
|
||||
Loaded 100 of 1440 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user