feat(medusa,dashboard,admin-sdk): Run admin dashboard from Medusa instance (#7330)

This commit is contained in:
Kasper Fabricius Kristensen
2024-05-15 19:52:09 +02:00
committed by GitHub
parent ec5415ea1a
commit 490586f566
82 changed files with 3946 additions and 788 deletions
@@ -1,14 +1,18 @@
import { Toaster } from "@medusajs/ui"
import { QueryClientProvider } from "@tanstack/react-query"
import { I18n } from "./components/utilities/i18n"
import { queryClient } from "./lib/medusa"
import { RouterProvider } from "./providers/router-provider"
import { ThemeProvider } from "./providers/theme-provider"
import "./index.css"
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18n />
<RouterProvider />
<Toaster />
</ThemeProvider>
@@ -64,9 +64,11 @@ export const FileUpload = ({
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: crypto.randomUUID(),
id: id,
url: previewUrl,
file,
}
@@ -13,13 +13,12 @@ import { Avatar, Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useTranslation } from "react-i18next"
import { ComponentType } from "react"
import { useStore } from "../../../hooks/api/store"
import { Skeleton } from "../../common/skeleton"
import { NavItem, NavItemProps } from "../../layout/nav-item"
import { Shell } from "../../layout/shell"
import extensions from "medusa-admin:routes/links"
export const MainLayout = () => {
return (
<Shell>
@@ -167,6 +166,10 @@ const CoreRouteSection = () => {
)
}
const extensions = {
links: null as { path: string; label: string; icon?: ComponentType }[] | null,
}
const ExtensionRouteSection = () => {
if (!extensions.links || extensions.links.length === 0) {
return null
@@ -1,7 +1,7 @@
import { DatePicker } from "@medusajs/ui"
import { ComponentPropsWithoutRef } from "react"
import { useTranslation } from "react-i18next"
import { languages } from "../../../i18n/config"
import { languages } from "../../../i18n/languages"
type LocalizedDatePickerProps = Omit<
ComponentPropsWithoutRef<typeof DatePicker>,
@@ -14,8 +14,9 @@ export const LocalizedDatePicker = ({
}: LocalizedDatePickerProps) => {
const { i18n, t } = useTranslation()
const locale = languages.find((lang) => lang.code === i18n.language)
?.date_locale
const locale = languages.find(
(lang) => lang.code === i18n.language
)?.date_locale
const translations = {
cancel: t("actions.cancel"),
@@ -0,0 +1,23 @@
import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next"
import { defaultI18nOptions } from "../../../i18n/config"
export const I18n = () => {
if (i18n.isInitialized) {
return null
}
i18n
.use(
new LanguageDetector(null, {
lookupCookie: "lng",
lookupLocalStorage: "lng",
})
)
.use(initReactI18next)
.init(defaultI18nOptions)
return null
}
@@ -0,0 +1 @@
export * from "./i18n"
@@ -2,7 +2,7 @@ import { format, formatDistance, sub } from "date-fns"
import { enUS } from "date-fns/locale"
import { useTranslation } from "react-i18next"
import { languages } from "../i18n/config"
import { languages } from "../i18n/languages"
export const useDate = () => {
const { i18n } = useTranslation()
@@ -1,34 +1,19 @@
import { enUS } from "date-fns/locale"
import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import Backend, { type HttpBackendOptions } from "i18next-http-backend"
import { initReactI18next } from "react-i18next"
import { InitOptions } from "i18next"
import { Language } from "./types"
import translations from "./translations"
void i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init<HttpBackendOptions>({
fallbackLng: "en-US",
load: "languageOnly",
debug: process.env.NODE_ENV === "development",
interpolation: {
escapeValue: false,
},
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
})
export const languages: Language[] = [
{
code: "en-US",
display_name: "English (US)",
ltr: true,
date_locale: enUS,
export const defaultI18nOptions: InitOptions = {
debug: process.env.NODE_ENV === "development",
detection: {
caches: ["cookie", "localStorage", "header"],
lookupCookie: "lng",
lookupLocalStorage: "lng",
order: ["cookie", "localStorage", "header"],
},
]
export default i18n
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
resources: translations,
supportedLngs: Object.keys(translations),
}
@@ -0,0 +1,11 @@
import { enUS } from "date-fns/locale"
import { Language } from "./types"
export const languages: Language[] = [
{
code: "en-US",
display_name: "English (US)",
ltr: true,
date_locale: enUS,
},
]
@@ -0,0 +1,315 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"general": {
"type": "object",
"properties": {
"cancel": {
"type": "string"
},
"save": {
"type": "string"
},
"create": {
"type": "string"
},
"delete": {
"type": "string"
},
"edit": {
"type": "string"
},
"extensions": {
"type": "string"
},
"details": {
"type": "string"
}
},
"required": [
"cancel",
"save",
"create",
"createItem",
"delete",
"deleteItem",
"edit",
"editItem",
"extensions",
"details"
]
},
"products": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"variants": {
"type": "string"
},
"availableInSalesChannels": {
"type": "string"
}
},
"required": ["domain", "variants", "availableInSalesChannels"]
},
"categories": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"collections": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"inventory": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"customers": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"customerGroups": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"orders": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"draftOrders": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"discounts": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"promotions": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"taxRegions": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"taxRates": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"fields": {
"type": "object"
}
},
"required": ["domain"]
},
"campaigns": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"giftCards": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"pricing": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"users": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"role": {
"type": "string"
},
"roles": {
"type": "object",
"properties": {
"admin": {
"type": "string"
},
"member": {
"type": "string"
},
"developer": {
"type": "string"
}
},
"required": ["admin", "member", "developer"]
}
},
"required": ["domain", "role", "roles"]
},
"statuses": {
"type": "object",
"properties": {
"scheduled": {
"type": "string"
},
"expired": {
"type": "string"
},
"active": {
"type": "string"
},
"disabled": {
"type": "string"
}
}
},
"fields": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"handle": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"categories": {
"type": "string"
},
"collection": {
"type": "string"
},
"discountable": {
"type": "string"
},
"tags": {
"type": "string"
},
"sales_channels": {
"type": "string"
}
},
"required": [
"title",
"description",
"name",
"email",
"password",
"subtitle",
"handle",
"type",
"category",
"categories",
"collection",
"discountable",
"tags",
"sales_channels"
]
}
},
"required": [
"general",
"products",
"categories",
"collections",
"inventory",
"customers",
"customerGroups",
"orders",
"draftOrders",
"discounts",
"giftCards",
"pricing",
"users",
"fields"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
import en from "./en.json"
export default {
en: {
translation: en,
},
}
@@ -1,8 +1,8 @@
import type { Locale } from "date-fns"
import en from "../../public/locales/en-US/translation.json"
import enUS from "./translations/en.json"
const resources = {
translation: en,
translation: enUS,
} as const
export type Resources = typeof resources
@@ -1,7 +1,6 @@
import { stringify } from "qs"
const baseUrl =
import.meta.env.VITE_MEDUSA_ADMIN_BACKEND_URL || "http://localhost:9000"
const baseUrl = __BACKEND_URL__ ?? "http://localhost:9000"
const commonHeaders: HeadersInit = {
Accept: "application/json",
@@ -1,8 +1,7 @@
import Medusa from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
export const MEDUSA_BACKEND_URL =
import.meta.env.VITE_MEDUSA_ADMIN_BACKEND_URL || "http://localhost:9000"
export const MEDUSA_BACKEND_URL = __BACKEND_URL__ ?? "http://localhost:9000"
export const queryClient = new QueryClient({
defaultOptions: {
@@ -1,8 +1,6 @@
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./app.js"
import "./i18n/config.js"
import "./index.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
@@ -1,16 +1,18 @@
import { RouteObject } from "react-router-dom"
import routes from "medusa-admin:routes/pages"
// import routes from "medusa-admin:routes/pages"
export const RouteExtensions: RouteObject[] = []
/**
* UI Route extensions.
*/
export const RouteExtensions: RouteObject[] = routes.pages.map((ext) => {
return {
path: ext.path,
async lazy() {
const { default: Component } = await import(/* @vite-ignore */ ext.file)
return { Component }
},
}
})
// export const RouteExtensions: RouteObject[] = routes.pages.map((ext) => {
// return {
// path: ext.path,
// async lazy() {
// const { default: Component } = await import(/* @vite-ignore */ ext.file)
// return { Component }
// },
// }
// })
@@ -15,9 +15,9 @@ import {
import { Outlet, RouteObject } from "react-router-dom"
import { ProtectedRoute } from "../../components/authentication/protected-route"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import { ErrorBoundary } from "../../components/utilities/error-boundary"
import { InventoryItemRes, PriceListRes } from "../../types/api-responses"
import { RouteExtensions } from "./route-extensions"
@@ -5,7 +5,9 @@ import {
import { RouteMap } from "./route-map"
const router = createBrowserRouter(RouteMap)
const router = createBrowserRouter(RouteMap, {
basename: __BASE__ || "/",
})
export const RouterProvider = () => {
return <Provider router={router} />
@@ -1,15 +1,19 @@
import settings from "medusa-admin:settings/pages"
import { RouteObject } from "react-router-dom"
// import settings from "medusa-admin:settings/pages"
/**
* UI Settings extensions.
*/
export const SettingsExtensions: RouteObject[] = settings.pages.map((ext) => {
return {
path: `/settings${ext.path}`,
async lazy() {
const { default: Component } = await import(/* @vite-ignore */ ext.file)
return { Component }
},
}
})
export const SettingsExtensions: RouteObject[] = []
// export const SettingsExtensions: RouteObject[] = settings.pages.map((ext) => {
// return {
// path: `/settings${ext.path}`,
// async lazy() {
// const { default: Component } = await import(/* @vite-ignore */ ext.file)
// return { Component }
// },
// }
// })
@@ -1,28 +1,30 @@
import { Container, Heading } from "@medusajs/ui";
import { Container, Heading } from "@medusajs/ui"
import after from "medusa-admin:widgets/product_category/details/after";
import before from "medusa-admin:widgets/product_category/details/before";
// import after from "medusa-admin:widgets/product_category/details/after"
// import before from "medusa-admin:widgets/product_category/details/before"
export const CategoryDetails = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
{/* {before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
)
})} */}
<Container>
<Heading>Category</Heading>
</Container>
{after.widgets.map((w, i) => {
{/* {after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
)
})} */}
</div>
);
};
)
}
@@ -1,28 +1,30 @@
import { Container, Heading } from "@medusajs/ui";
import { Container, Heading } from "@medusajs/ui"
import after from "medusa-admin:widgets/product_category/list/after";
import before from "medusa-admin:widgets/product_category/list/before";
// import after from "medusa-admin:widgets/product_category/list/after"
// import before from "medusa-admin:widgets/product_category/list/before"
export const CategoriesList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
{/* {before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
)
})} */}
<Container>
<Heading>Categories</Heading>
</Container>
{after.widgets.map((w, i) => {
{/* {after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
)
})} */}
</div>
);
};
)
}
@@ -10,8 +10,8 @@ import { DiscountConditionsSection } from "./components/discount-conditions-sect
import { RedemptionsSection } from "./components/discount-redemptions-section"
import { discountLoader, expand } from "./loader"
import after from "medusa-admin:widgets/discount/details/after"
import before from "medusa-admin:widgets/discount/details/before"
// import after from "medusa-admin:widgets/discount/details/after"
// import before from "medusa-admin:widgets/discount/details/before"
export const DiscountDetail = () => {
const initialData = useLoaderData() as Awaited<
@@ -33,13 +33,14 @@ export const DiscountDetail = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
{/* {before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-2">
<DiscountGeneralSection discount={discount} />
@@ -49,13 +50,15 @@ export const DiscountDetail = () => {
<RedemptionsSection redemptions={discount.usage_count} />
<DetailsSection discount={discount} />
</div>
{after.widgets.map((w, i) => {
{/* {after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<JsonViewSection data={discount} />
</div>
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
@@ -1,18 +1,20 @@
import after from "medusa-admin:widgets/discount/list/after"
import before from "medusa-admin:widgets/discount/list/before"
// import after from "medusa-admin:widgets/discount/list/after"
// import before from "medusa-admin:widgets/discount/list/before"
import { DiscountListTable } from "./components/discount-list-table"
export const DiscountsList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
{/* {before.widgets.map((w, i) => (
<w.Component key={i} />
))}
))} */}
<DiscountListTable />
{after.widgets.map((w, i) => (
{/* {after.widgets.map((w, i) => (
<w.Component key={i} />
))}
))} */}
</div>
)
}
@@ -9,12 +9,13 @@ import { ProductSalesChannelSection } from "./components/product-sales-channel-s
import { ProductVariantSection } from "./components/product-variant-section"
import { productLoader } from "./loader"
import after from "medusa-admin:widgets/product/details/after"
import before from "medusa-admin:widgets/product/details/before"
import sideAfter from "medusa-admin:widgets/product/details/side/after"
import sideBefore from "medusa-admin:widgets/product/details/side/before"
import { ProductOrganizationSection } from "./components/product-organization-section"
// import after from "medusa-admin:widgets/product/details/after"
// import before from "medusa-admin:widgets/product/details/before"
// import sideAfter from "medusa-admin:widgets/product/details/side/after"
// import sideBefore from "medusa-admin:widgets/product/details/side/before"
import { useProduct } from "../../../hooks/api/products"
import { ProductOrganizationSection } from "./components/product-organization-section"
// TODO: Use product domain translations only
export const ProductDetail = () => {
@@ -37,50 +38,50 @@ export const ProductDetail = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
{/* {before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<div className="flex flex-col gap-x-4 lg:flex-row lg:items-start">
<div className="w-full flex flex-col gap-y-2">
<div className="flex w-full flex-col gap-y-2">
<ProductGeneralSection product={product} />
<ProductMediaSection product={product} />
<ProductOptionSection product={product} />
<ProductVariantSection product={product} />
{after.widgets.map((w, i) => {
{/* {after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<div className="hidden lg:block">
<JsonViewSection data={product} root="product" />
</div>
</div>
<div className="w-full lg:max-w-[400px] max-w-[100%] mt-2 lg:mt-0 flex flex-col gap-y-2">
{sideBefore.widgets.map((w, i) => {
<div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 lg:mt-0 lg:max-w-[400px]">
{/* {sideBefore.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<ProductSalesChannelSection product={product} />
<ProductOrganizationSection product={product} />
<ProductAttributeSection product={product} />
{sideAfter.widgets.map((w, i) => {
{/* {sideAfter.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
})} */}
<div className="lg:hidden">
<JsonViewSection data={product} root="product" />
@@ -1,18 +1,9 @@
import after from "medusa-admin:widgets/product/list/after"
import before from "medusa-admin:widgets/product/list/before"
import { ProductListTable } from "./components/product-list-table"
export const ProductList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
<w.Component key={i} />
))}
<ProductListTable />
{after.widgets.map((w, i) => (
<w.Component key={i} />
))}
</div>
)
}
@@ -9,15 +9,15 @@ import { useTranslation } from "react-i18next"
import { z } from "zod"
import { Link } from "react-router-dom"
import {
FileType,
FileUpload,
} from "../../../../../components/common/file-upload"
import { Form } from "../../../../../components/common/form"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import {
FileType,
FileUpload,
} from "../../../../../components/common/file-upload"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductMediaViewProps = {
@@ -400,8 +400,10 @@ const getDefaultValues = (images: Image[] | null, thumbnail: string | null) => {
})) || []
if (thumbnail && !media.some((mediaItem) => mediaItem.url === thumbnail)) {
const id = Math.random().toString(36).substring(7)
media.unshift({
id: crypto.randomUUID(),
id: id,
url: thumbnail,
isThumbnail: true,
file: null,
@@ -2,7 +2,7 @@ import { UserDTO } from "@medusajs/types"
import { Button, Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { languages } from "../../../../../i18n/config"
import { languages } from "../../../../../i18n/languages"
type ProfileGeneralSectionProps = {
user: UserDTO
@@ -11,7 +11,7 @@ import {
useRouteModal,
} from "../../../../../components/route-modal"
import { useUpdateUser } from "../../../../../hooks/api/users"
import { languages } from "../../../../../i18n/config"
import { languages } from "../../../../../i18n/languages"
type EditProfileProps = {
user: Partial<Omit<UserDTO, "password_hash">>
@@ -7,9 +7,6 @@ import { PromotionConditionsSection } from "./components/promotion-conditions-se
import { PromotionGeneralSection } from "./components/promotion-general-section"
import { promotionLoader } from "./loader"
import after from "medusa-admin:widgets/promotion/details/after"
import before from "medusa-admin:widgets/promotion/details/before"
export const PromotionDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof promotionLoader>
@@ -27,14 +24,6 @@ export const PromotionDetail = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-2">
<PromotionGeneralSection promotion={promotion} />
@@ -57,14 +46,6 @@ export const PromotionDetail = () => {
<CampaignSection campaign={promotion.campaign!} />
</div>
{after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<JsonViewSection data={promotion as any} />
</div>
@@ -1,20 +1,9 @@
import after from "medusa-admin:widgets/promotion/list/after"
import before from "medusa-admin:widgets/promotion/list/before"
import { PromotionListTable } from "./components/promotion-list-table"
export const PromotionsList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
<w.Component key={i} />
))}
<PromotionListTable />
{after.widgets.map((w, i) => (
<w.Component key={i} />
))}
</div>
)
}
+3
View File
@@ -8,3 +8,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __BACKEND_URL__: string | undefined
declare const __BASE__: string