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:
Kasper Fabricius Kristensen authored and GitHub committed 2024-09-04 19:00:25 +00:00
1 parent beaa851302
commit 0fe1201435
1440 files changed
+122 -86

No files matched your search

+1
View File
@@ -0,0 +1 @@
# `@medusajs/admin-bundler`
+44
View File
@@ -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,
})
+1
View File
@@ -0,0 +1 @@
# @medusajs/admin-sdk
+30
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
export * from "./config"
+19
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
})
+1
View File
@@ -0,0 +1 @@
# @medusajs/admin-shared
+27
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./extensions/virtual"
export * from "./extensions/widgets"
+19
View File
@@ -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,
})
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel
+30
View File
@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Medusa Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+87
View File
@@ -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: {},
},
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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)
}
})()
+31
View File
@@ -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
@@ -0,0 +1 @@
export * from "./protected-route"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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";
@@ -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"
@@ -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