chore: move next admin packages to core repo (#5983)

**What**
- Move packages for `next` version of admin to core repo

**Other**
- Since this PR introduces packages that depend on Vite 5, it also introduces @types/node@^20. We have never had a direct dependency on the types package for Node, and as far as I can see that has resulted in us using the types from Node.js@8, as those are a dependency of one of our dependencies. With the introduction of @types/node@^20, two of our packages had TS errors because they were using the NodeJS.Timer type, which was deprecated in Node.js@14. We should add specific @types/node packages to all our packages, but I haven't done so in this PR to keep it as clean as possible.
- Q: @olivermrbl I've added the new packages to the ignore list for changeset, is this enough to prevent them from being published?
This commit is contained in:
Kasper Fabricius Kristensen
2024-01-08 09:26:46 +00:00
committed by GitHub
parent 479a8b82a9
commit f868775861
491 changed files with 11332 additions and 428 deletions
+31
View File
@@ -0,0 +1,31 @@
import { Toaster } from "@medusajs/ui"
import { MedusaProvider } from "medusa-react"
import { AuthProvider } from "./providers/auth-provider"
import { RouterProvider } from "./providers/router-provider"
import { ThemeProvider } from "./providers/theme-provider"
import { queryClient } from "./lib/medusa"
const BASE_URL =
import.meta.env.VITE_MEDUSA_ADMIN_BACKEND_URL || "http://localhost:9000"
function App() {
return (
<MedusaProvider
baseUrl={BASE_URL}
queryClientProviderProps={{
client: queryClient,
}}
>
<ThemeProvider>
<AuthProvider>
<RouterProvider />
<Toaster />
</AuthProvider>
</ThemeProvider>
</MedusaProvider>
)
}
export default App
@@ -0,0 +1 @@
export * from "./require-auth";
@@ -0,0 +1,25 @@
import { Spinner } from "@medusajs/icons";
import { PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../../../providers/auth-provider";
export const RequireAuth = ({ children }: PropsWithChildren) => {
const auth = useAuth();
const location = useLocation();
if (auth.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spinner className="animate-spin text-ui-fg-interactive" />
</div>
);
}
if (!auth.user) {
console.log("redirecting");
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
@@ -0,0 +1,45 @@
import { Input } from "@medusajs/ui"
import { ComponentProps, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
type DebouncedSearchProps = Omit<
ComponentProps<typeof Input>,
"value" | "defaultValue" | "onChange" | "type"
> & {
debounce?: number
value: string
onChange: (value: string) => void
}
export const DebouncedSearch = ({
value: initialValue,
onChange,
debounce = 500,
placeholder,
...props
}: DebouncedSearchProps) => {
const [value, setValue] = useState<string>(initialValue)
const { t } = useTranslation()
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
const timeout = setTimeout(() => {
onChange?.(value)
}, debounce)
return () => clearTimeout(timeout)
}, [value])
return (
<Input
{...props}
placeholder={placeholder || t("general.search")}
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
@@ -0,0 +1 @@
export * from "./debounced-search"
@@ -0,0 +1,199 @@
import {
Hint as HintComponent,
Label as LabelComponent,
Text,
clx,
} from "@medusajs/ui"
import * as LabelPrimitives from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import { createContext, forwardRef, useContext, useId } from "react"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form"
import { useTranslation } from "react-i18next"
const Provider = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const Field = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
type FormItemContextValue = {
id: string
}
const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const useFormField = () => {
const fieldContext = useContext(FormFieldContext)
const itemContext = useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within a FormField")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={clx("flex flex-col space-y-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
)
Item.displayName = "Form.Item"
const Label = forwardRef<
React.ElementRef<typeof LabelPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
optional?: boolean
}
>(({ className, optional = false, ...props }, ref) => {
const { formItemId } = useFormField()
const { t } = useTranslation()
return (
<div className="flex items-center gap-x-1">
<LabelComponent
ref={ref}
className={clx(className)}
htmlFor={formItemId}
size="small"
weight="plus"
{...props}
/>
{optional && (
<Text size="small" leading="compact" className="text-ui-fg-muted">
({t("fields.optional")})
</Text>
)}
</div>
)
})
Label.displayName = "Form.Label"
const Control = forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formErrorMessageId } =
useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formErrorMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
Control.displayName = "Form.Control"
const Hint = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<HintComponent
ref={ref}
id={formDescriptionId}
className={className}
{...props}
/>
)
})
Hint.displayName = "Form.Hint"
const ErrorMessage = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg) {
return null
}
return (
<HintComponent
ref={ref}
id={formErrorMessageId}
className={className}
variant={error ? "error" : "info"}
{...props}
>
{msg}
</HintComponent>
)
})
ErrorMessage.displayName = "Form.ErrorMessage"
const Form = Object.assign(Provider, {
Item,
Label,
Control,
Hint,
ErrorMessage,
Field,
})
export { Form }
@@ -0,0 +1 @@
export * from "./form";
@@ -0,0 +1 @@
export * from "./json-view";
@@ -0,0 +1,116 @@
import {
ArrowsPointingOut,
CheckCircleMiniSolid,
SquareTwoStackMini,
XMarkMini,
} from "@medusajs/icons"
import {
Badge,
Container,
Drawer,
Heading,
IconButton,
Kbd,
} from "@medusajs/ui"
import Primitive from "@uiw/react-json-view"
import { CSSProperties, Suspense } from "react"
type JsonViewProps = {
data: object
root?: string
}
// TODO: Fix the positioning of the copy btn
export const JsonView = ({ data, root }: JsonViewProps) => {
const numberOfKeys = Object.keys(data).length
return (
<Container className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-4">
<Heading level="h2">JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
</div>
<Drawer>
<Drawer.Trigger asChild>
<IconButton variant="transparent" className="text-ui-fg-subtle">
<ArrowsPointingOut />
</IconButton>
</Drawer.Trigger>
<Drawer.Content className="border-ui-code-border bg-ui-code-bg-base text-ui-code-text-base dark overflow-hidden border shadow-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-8 py-6">
<div className="flex items-center gap-x-4">
<Heading>JSON</Heading>
<Badge>{numberOfKeys} keys</Badge>
</div>
<div className="flex items-center gap-x-2">
<Kbd>esc</Kbd>
<Drawer.Close asChild>
<IconButton variant="transparent" className="text-ui-fg-subtle">
<XMarkMini />
</IconButton>
</Drawer.Close>
</div>
</div>
<Drawer.Body className="overflow-auto p-4">
<Suspense fallback={<div>Loading...</div>}>
<Primitive
value={data}
displayDataTypes={false}
keyName={root}
style={
{
"--w-rjv-font-family": "Roboto Mono, monospace",
"--w-rjv-line-color": "#2E3035",
"--w-rjv-curlybraces-color": "#ADB1B8",
"--w-rjv-key-string": "#A78BFA",
"--w-rjv-info-color": "#FBBF24",
"--w-rjv-type-string-color": "#34D399",
"--w-rjv-quotes-string-color": "#34D399",
"--w-rjv-type-boolean-color": "#FBBF24",
"--w-rjv-type-int-color": "#60A5FA",
"--w-rjv-type-float-color": "#60A5FA",
"--w-rjv-type-bigint-color": "#60A5FA",
"--w-rjv-key-number": "#60A5FA",
} as CSSProperties
}
collapsed={1}
>
<Primitive.Copied
// @ts-expect-error - types are missing the 'data-copied' prop
render={({ "data-copied": copied, onClick }) => {
if (copied) {
return (
<CheckCircleMiniSolid className="text-ui-fg-subtle cursor-pointer align-middle" />
)
}
return (
<SquareTwoStackMini
className="text-ui-fg-subtle cursor-pointer align-middle"
onClick={onClick}
/>
)
}}
/>
<Primitive.Quote render={() => " "} />
<Primitive.Null
render={() => (
<span className="text-ui-tag-red-text">null</span>
)}
/>
<Primitive.CountInfo
render={(_props, { value }) => {
return (
<span className="text-ui-tag-neutral-text ml-2">
{Object.keys(value as object).length} items
</span>
)
}}
/>
</Primitive>
</Suspense>
</Drawer.Body>
</Drawer.Content>
</Drawer>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./product-table-cells"
@@ -0,0 +1,133 @@
import {
Product,
ProductCollection,
ProductVariant,
SalesChannel,
} from "@medusajs/medusa"
import { StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../thumbnail"
export const ProductInventoryCell = ({
variants,
}: {
variants: ProductVariant[] | null
}) => {
const { t } = useTranslation()
if (!variants || !variants.length) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
const inventory = variants.reduce((acc, v) => acc + v.inventory_quantity, 0)
return (
<Text size="small" className="text-ui-fg-base">
{t("products.inStockVariants", {
count: variants.length,
inventory: inventory,
})}
</Text>
)
}
export const ProductStatusCell = ({
status,
}: {
status: Product["status"]
}) => {
const { t } = useTranslation()
const color = {
draft: "grey",
published: "green",
rejected: "red",
proposed: "blue",
}[status] as "grey" | "green" | "red" | "blue"
return (
<StatusBadge color={color}>
{t(`products.productStatus.${status}`)}
</StatusBadge>
)
}
export const ProductAvailabilityCell = ({
salesChannels,
}: {
salesChannels: SalesChannel[] | null
}) => {
const { t } = useTranslation()
if (!salesChannels || salesChannels.length === 0) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
if (salesChannels.length < 3) {
return (
<Text size="small" className="text-ui-fg-base">
{salesChannels.map((sc) => sc.name).join(", ")}
</Text>
)
}
return (
<div className="flex items-center gap-x-2">
<Text size="small" className="text-ui-fg-base">
<span>
{salesChannels
.slice(0, 2)
.map((sc) => sc.name)
.join(", ")}
</span>{" "}
<span>
{t("general.plusCountMore", {
count: salesChannels.length - 2,
})}
</span>
</Text>
</div>
)
}
export const ProductTitleCell = ({ product }: { product: Product }) => {
const thumbnail = product.thumbnail
const title = product.title
return (
<div className="flex items-center gap-x-3">
<Thumbnail src={thumbnail} alt={`Thumbnail image of ${title}`} />
<Text size="small" className="text-ui-fg-base">
{title}
</Text>
</div>
)
}
export const ProductCollectionCell = ({
collection,
}: {
collection: ProductCollection | null
}) => {
if (!collection) {
return (
<Text size="small" className="text-ui-fg-subtle">
-
</Text>
)
}
return (
<Text size="small" className="text-ui-fg-base">
{collection.title}
</Text>
)
}
@@ -0,0 +1 @@
export * from "./thumbnail";
@@ -0,0 +1,22 @@
import { Photo } from "@medusajs/icons";
type ThumbnailProps = {
src?: string | null;
alt?: string;
};
export const Thumbnail = ({ src, alt }: ThumbnailProps) => {
return (
<div className="bg-ui-bg-component flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
{src ? (
<img
src={src}
alt={alt}
className="h-full w-full object-cover object-center"
/>
) : (
<Photo />
)}
</div>
);
};
@@ -0,0 +1,26 @@
import type { AxiosError } from "axios"
import { Navigate, useLocation, useRouteError } from "react-router-dom"
export const ErrorBoundary = () => {
const error = useRouteError()
const location = useLocation()
if (isAxiosError(error)) {
if (error.response?.status === 404) {
return <Navigate to="/404" />
}
if (error.response?.status === 401) {
return <Navigate to="/login" state={{ from: location }} replace />
}
// TODO: Catch other server errors
}
// TODO: Actual catch-all error page
return <div>Dang!</div>
}
const isAxiosError = (error: any): error is AxiosError => {
return error.isAxiosError
}
@@ -0,0 +1 @@
export { ErrorBoundary } from "./error-boundary"
@@ -0,0 +1,26 @@
import { Outlet, useLocation } from "react-router-dom"
import { Gutter } from "./gutter"
import { MainNav } from "./main-nav"
import { SettingsNav } from "./settings-nav"
import { Topbar } from "./topbar"
export const AppLayout = () => {
const location = useLocation()
const isSettings = location.pathname.startsWith("/settings")
return (
<div className="flex h-screen flex-col items-start overflow-hidden md:flex-row">
<MainNav />
<div className="flex h-[calc(100vh-57px)] w-full md:h-screen">
{isSettings && <SettingsNav />}
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-4">
<Gutter>
<Topbar />
<Outlet />
</Gutter>
</div>
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import { TriangleRightMini } from "@medusajs/icons";
import { clx } from "@medusajs/ui";
import { Link, UIMatch, useMatches } from "react-router-dom";
type BreadcrumbProps = React.ComponentPropsWithoutRef<"ol">;
export const Breadcrumbs = ({ className, ...props }: BreadcrumbProps) => {
const matches = useMatches() as unknown as UIMatch<
unknown,
{ crumb?: (data?: unknown) => string }
>[];
const crumbs = matches
.filter((match) => Boolean(match.handle?.crumb))
.map((match) => {
const handle = match.handle;
return {
label: handle.crumb!(match.data),
path: match.pathname,
};
});
if (crumbs.length < 2) {
return null;
}
return (
<ol
className={clx("flex items-center gap-x-1 text-ui-fg-muted", className)}
{...props}
>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;
return (
<li
key={index}
className="txt-compact-small-plus flex items-center gap-x-1"
>
{!isLast ? (
<Link to={crumb.path}>{crumb.label}</Link>
) : (
<span key={index}>{crumb.label}</span>
)}
{!isLast && <TriangleRightMini />}
</li>
);
})}
</ol>
);
};
@@ -0,0 +1,9 @@
import { PropsWithChildren } from "react";
export const Gutter = ({ children }: PropsWithChildren) => {
return (
<div className="w-full max-w-[1200px] flex flex-col gap-y-4">
{children}
</div>
);
};
@@ -0,0 +1 @@
export * from "./app-layout";
@@ -0,0 +1,26 @@
import { QueryClient } from "@tanstack/react-query";
import { adminProductKeys } from "medusa-react";
import { LoaderFunctionArgs } from "react-router-dom";
import { medusa, queryClient } from "../../../lib/medusa";
const appLoaderQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.products.retrieve(id),
});
export const productLoader = (client: QueryClient) => {
return async ({ params }: LoaderFunctionArgs) => {
const id = params?.id;
if (!id) {
throw new Error("No id provided");
}
const query = appLoaderQuery(id);
return (
queryClient.getQueryData(query.queryKey) ??
(await client.fetchQuery(query))
);
};
};
@@ -0,0 +1,380 @@
import {
ArrowRightOnRectangle,
BookOpen,
BuildingStorefront,
Calendar,
ChevronDownMini,
CircleHalfSolid,
CogSixTooth,
CurrencyDollar,
EllipsisHorizontal,
MinusMini,
ReceiptPercent,
ShoppingCart,
Sidebar,
SquaresPlus,
Tag,
Users,
} from "@medusajs/icons"
import { Avatar, DropdownMenu, IconButton, Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import * as Dialog from "@radix-ui/react-dialog"
import { useAdminDeleteSession, useAdminStore } from "medusa-react"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { useAuth } from "../../../providers/auth-provider"
import { useTheme } from "../../../providers/theme-provider"
import { Fragment, useEffect, useState } from "react"
import { Breadcrumbs } from "./breadcrumbs"
import { NavItem, NavItemProps } from "./nav-item"
import { Notifications } from "./notifications"
import { SearchToggle } from "./search-toggle"
import { Spacer } from "./spacer"
import extensions from "medusa-admin:routes/links"
import { useTranslation } from "react-i18next"
export const MainNav = () => {
return (
<Fragment>
<DesktopNav />
<MobileNav />
</Fragment>
)
}
const MobileNav = () => {
const [open, setOpen] = useState(false)
const location = useLocation()
// If the user navigates to a new route, we want to close the menu
useEffect(() => {
setOpen(false)
}, [location.pathname])
return (
<div className="bg-ui-bg-base border-ui-border-base flex h-[57px] w-full items-center justify-between border-b px-4 md:hidden">
<Dialog.Root open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-x-2">
<Dialog.Trigger asChild>
<IconButton variant="transparent">
<Sidebar />
</IconButton>
</Dialog.Trigger>
<Breadcrumbs />
</div>
<Dialog.Portal>
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0 lg:hidden" />
<Dialog.Content className="bg-ui-bg-subtle fixed inset-y-0 left-0 flex w-full flex-col overflow-y-auto sm:max-w-[240px] lg:hidden">
<div className="flex flex-1 flex-col">
<div className="sticky top-0">
<Header />
<Spacer />
</div>
<CoreRouteSection />
<ExtensionRouteSection />
</div>
<div className="sticky bottom-0 flex w-full flex-col">
<SettingsSection />
<Spacer />
<UserSection />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<div className="flex items-center gap-x-2">
<SearchToggle />
<Notifications />
</div>
</div>
)
}
const DesktopNav = () => {
return (
<aside className="flex h-full max-h-screen w-full max-w-[240px] flex-col justify-between overflow-y-auto max-md:hidden">
<div className="flex flex-1 flex-col">
<div className="bg-ui-bg-subtle sticky top-0">
<Header />
<Spacer />
</div>
<CoreRouteSection />
<ExtensionRouteSection />
</div>
<div className="bg-ui-bg-subtle sticky bottom-0 flex flex-col">
<SettingsSection />
<Spacer />
<UserSection />
</div>
</aside>
)
}
const Header = () => {
const { store } = useAdminStore()
const { setTheme, theme } = useTheme()
const { mutateAsync: logoutMutation } = useAdminDeleteSession()
const navigate = useNavigate()
const logout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
navigate("/login")
},
})
}
if (!store) {
return null
}
return (
<div className="w-full p-4">
<DropdownMenu>
<DropdownMenu.Trigger className="hover:bg-ui-bg-subtle-hover active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed transition-fg w-full rounded-md outline-none">
<div className="flex items-center justify-between p-1 md:pr-2">
<div className="flex items-center gap-x-3">
<div className="bg-ui-bg-base shadow-borders-base flex h-8 w-8 items-center justify-center overflow-hidden rounded-md">
<div className="bg-ui-bg-component flex h-[28px] w-[28px] items-center justify-center overflow-hidden rounded-[4px]">
{store.name[0].toUpperCase()}
</div>
</div>
<Text size="small" weight="plus" leading="compact">
{store.name}
</Text>
</div>
<div className="text-ui-fg-subtle">
<EllipsisHorizontal />
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>
<BuildingStorefront className="text-ui-fg-subtle mr-2" />
Store Settings
</DropdownMenu.Item>
<DropdownMenu.Separator />
<Link to="https://docs.medusajs.com/user-guide" target="_blank">
<DropdownMenu.Item>
<BookOpen className="text-ui-fg-subtle mr-2" />
Documentation
</DropdownMenu.Item>
</Link>
<Link to="https://medusajs.com/changelog/" target="_blank">
<DropdownMenu.Item>
<Calendar className="text-ui-fg-subtle mr-2" />
Changelog
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.SubMenu>
<DropdownMenu.SubMenuTrigger className="rounded-md">
<CircleHalfSolid className="text-ui-fg-subtle mr-2" />
Theme
</DropdownMenu.SubMenuTrigger>
<DropdownMenu.SubMenuContent>
<DropdownMenu.RadioGroup value={theme}>
<DropdownMenu.RadioItem
value="light"
onClick={(e) => {
e.preventDefault()
setTheme("light")
}}
>
Light
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
value="dark"
onClick={(e) => {
e.preventDefault()
setTheme("dark")
}}
>
Dark
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.SubMenuContent>
</DropdownMenu.SubMenu>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={logout}>
<ArrowRightOnRectangle className="text-ui-fg-subtle mr-2" />
Logout
<DropdownMenu.Shortcut>Q</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
)
}
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
const { t } = useTranslation()
return [
{
icon: <ShoppingCart />,
label: t("orders.domain"),
to: "/orders",
items: [
{
label: t("draftOrders.domain"),
to: "/draft-orders",
},
],
},
{
icon: <Tag />,
label: t("products.domain"),
to: "/products",
items: [
{
label: t("collections.domain"),
to: "/collections",
},
{
label: t("categories.domain"),
to: "/categories",
},
{
label: t("giftCards.domain"),
to: "/gift-cards",
},
{
label: t("inventory.domain"),
to: "/inventory",
},
],
},
{
icon: <Users />,
label: t("customers.domain"),
to: "/customers",
items: [
{
label: t("customerGroups.domain"),
to: "/customer-groups",
},
],
},
{
icon: <ReceiptPercent />,
label: t("discounts.domain"),
to: "/discounts",
},
{
icon: <CurrencyDollar />,
label: t("pricing.domain"),
to: "/pricing",
},
]
}
const CoreRouteSection = () => {
const coreRoutes = useCoreRoutes()
return (
<nav className="flex flex-col gap-y-1 py-4">
{coreRoutes.map((route) => {
return <NavItem key={route.to} {...route} />
})}
</nav>
)
}
const ExtensionRouteSection = () => {
if (!extensions.links || extensions.links.length === 0) {
return null
}
return (
<div>
<Spacer />
<div className="flex flex-col gap-y-4 py-4">
<Collapsible.Root defaultOpen>
<div className="px-4">
<Collapsible.Trigger asChild className="group/trigger">
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
<Text size="xsmall" weight="plus" leading="compact">
Extensions
</Text>
<div className="text-ui-fg-muted">
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
</div>
</button>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-1 py-1 pb-4">
{extensions.links.map((link) => {
return (
<NavItem
key={link.path}
to={link.path}
label={link.label}
icon={link.icon ? <link.icon /> : <SquaresPlus />}
type="extension"
/>
)
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
)
}
const SettingsSection = () => {
return (
<div className="py-4">
<NavItem icon={<CogSixTooth />} label="Settings" to="/settings" />
</div>
)
}
const UserSection = () => {
const { user } = useAuth()
if (!user) {
return null
}
const fallback =
user.first_name && user.last_name
? `${user.first_name[0]}${user.last_name[0]}`
: user.first_name
? user.first_name[0]
: user.email[0]
return (
<div className="p-4">
<Link
to="/settings/profile"
className="hover:bg-ui-bg-subtle-hover transition-fg active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed flex items-center gap-x-3 rounded-md p-1 outline-none"
>
<Avatar fallback={fallback.toUpperCase()} />
<div className="flex flex-1 flex-col">
{(user.first_name || user.last_name) && (
<Text
size="xsmall"
weight="plus"
leading="compact"
className="max-w-[90%] truncate"
>{`${user.first_name && `${user.first_name} `}${
user.last_name
}`}</Text>
)}
<Text
size="xsmall"
leading="compact"
className="text-ui-fg-subtle max-w-[90%] truncate"
>
{user.email}
</Text>
</div>
</Link>
</div>
)
}
@@ -0,0 +1,148 @@
import { Text, clx } from "@medusajs/ui";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
type ItemType = "core" | "extension";
type NestedItemProps = {
label: string;
to: string;
};
export type NavItemProps = {
icon?: React.ReactNode;
label: string;
to: string;
items?: NestedItemProps[];
type?: ItemType;
};
export const NavItem = ({
icon,
label,
to,
items,
type = "core",
}: NavItemProps) => {
const location = useLocation();
const [open, setOpen] = useState(
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
location.pathname.startsWith(p)
)
);
useEffect(() => {
setOpen(
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
location.pathname.startsWith(p)
)
);
}, [location.pathname, to, items]);
return (
<div className="px-4">
<Link
to={to}
className={clx(
"text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
{
"bg-ui-bg-base shadow-elevation-card-rest":
location.pathname.startsWith(to),
"max-md:hidden": items && items.length > 0,
}
)}
>
<Icon icon={icon} type={type} />
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</Link>
{items && items.length > 0 && (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger
className={clx(
"w-full md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover"
)}
>
<Icon icon={icon} type={type} />
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</Collapsible.Trigger>
<Collapsible.Content className="flex flex-col gap-y-1 pt-1">
<Link
to={to}
className={clx(
"md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
{
"bg-ui-bg-base shadow-elevation-card-rest":
location.pathname.startsWith(to),
}
)}
>
<div className="w-5 h-5 flex items-center justify-center">
<div
className={clx(
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
{
"border-ui-fg-base border-2": location.pathname === to,
}
)}
/>
</div>
<Text size="small" weight="plus" leading="compact">
{label}
</Text>
</Link>
{items.map((item) => {
return (
<Link
to={item.to}
key={item.to}
className={clx(
"first-of-type:mt-1 last-of-type:mb-2 text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
{
"bg-ui-bg-base shadow-elevation-card-rest":
location.pathname === item.to,
}
)}
>
<div className="w-5 h-5 flex items-center justify-center">
<div
className={clx(
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
{
"border-ui-fg-base border-2":
location.pathname === item.to,
}
)}
/>
</div>
<Text size="small" weight="plus" leading="compact">
{item.label}
</Text>
</Link>
);
})}
</Collapsible.Content>
</Collapsible.Root>
)}
</div>
);
};
const Icon = ({ icon, type }: { icon?: React.ReactNode; type: ItemType }) => {
if (!icon) {
return null;
}
return type === "extension" ? (
<div className="rounded-[4px] w-5 h-5 flex items-center justify-center shadow-borders-base bg-ui-bg-base">
<div className="w-4 h-4 rounded-sm overflow-hidden">{icon}</div>
</div>
) : (
icon
);
};
@@ -0,0 +1,37 @@
import { BellAlert } from "@medusajs/icons";
import { Drawer, Heading, IconButton } from "@medusajs/ui";
import { useEffect, useState } from "react";
export const Notifications = () => {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "n" && (e.metaKey || e.ctrlKey)) {
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer.Trigger asChild>
<IconButton variant="transparent" className="text-ui-fg-muted">
<BellAlert />
</IconButton>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Heading>Notifications</Heading>
</Drawer.Header>
<Drawer.Body>Notifications will go here</Drawer.Body>
</Drawer.Content>
</Drawer>
);
};
@@ -0,0 +1,17 @@
import { MagnifyingGlass } from "@medusajs/icons"
import { IconButton } from "@medusajs/ui"
import { useSearch } from "../../../providers/search-provider"
export const SearchToggle = () => {
const { toggleSearch } = useSearch()
return (
<IconButton
variant="transparent"
onClick={toggleSearch}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
<MagnifyingGlass />
</IconButton>
)
}
@@ -0,0 +1,119 @@
import { ChevronDownMini, CogSixTooth, MinusMini } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { NavItem, NavItemProps } from "./nav-item"
import { Spacer } from "./spacer"
const useSettingRoutes = (): NavItemProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
label: t("profile.domain"),
to: "/settings/profile",
},
{
label: t("store.domain"),
to: "/settings/store",
},
{
label: t("users.domain"),
to: "/settings/users",
},
{
label: t("regions.domain"),
to: "/settings/regions",
},
{
label: t("currencies.domain"),
to: "/settings/currencies",
},
{
label: "Taxes",
to: "/settings/taxes",
},
{
label: "Locations",
to: "/settings/locations",
},
{
label: t("salesChannels.domain"),
to: "/settings/sales-channels",
},
{
label: t("apiKeyManagement.domain"),
to: "/settings/api-key-management",
},
],
[t]
)
}
export const SettingsNav = () => {
const routes = useSettingRoutes()
const { t } = useTranslation()
return (
<div className="border-ui-border-base box-content flex h-full max-h-screen w-full max-w-[240px] flex-col overflow-hidden border-x max-md:hidden">
<div className="p-4">
<div className="flex h-10 items-center gap-x-3 p-1">
<CogSixTooth className="text-ui-fg-subtle" />
<Text leading="compact" weight="plus" size="small">
{t("general.settings")}
</Text>
</div>
</div>
<Spacer />
<div className="flex flex-1 flex-col gap-y-4 overflow-y-auto py-4">
<Collapsible.Root defaultOpen>
<div className="px-4">
<Collapsible.Trigger asChild className="group/trigger">
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
<Text size="xsmall" weight="plus" leading="compact">
{t("general.general")}
</Text>
<div className="text-ui-fg-muted">
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
</div>
</button>
</Collapsible.Trigger>
</div>
<Collapsible.Content asChild>
<nav className="flex flex-col gap-y-1 py-1">
{routes.map((setting) => (
<NavItem key={setting.to} {...setting} />
))}
</nav>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root>
<div className="px-4">
<Collapsible.Trigger asChild className="group/trigger">
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
<Text size="xsmall" weight="plus" leading="compact">
{t("general.extensions")}
</Text>
<div className="text-ui-fg-muted">
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
</div>
</button>
</Collapsible.Trigger>
</div>
<Collapsible.Content asChild>
<nav className="flex flex-col gap-y-1 py-1">
{routes.map((setting) => (
<NavItem key={setting.to} {...setting} />
))}
</nav>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
)
}
@@ -0,0 +1,7 @@
export const Spacer = () => {
return (
<div className="px-4">
<div className="w-full h-px border-b border-dashed border-ui-border-strong" />
</div>
);
};
@@ -0,0 +1,19 @@
import { Sidebar } from "@medusajs/icons"
import { Breadcrumbs } from "./breadcrumbs"
import { Notifications } from "./notifications"
import { SearchToggle } from "./search-toggle"
export const Topbar = () => {
return (
<div className="hidden items-center justify-between px-4 py-1 md:flex">
<div className="flex items-center gap-x-1.5">
<Sidebar className="text-ui-fg-muted" />
<Breadcrumbs />
</div>
<div className="text-ui-fg-muted flex items-center gap-x-1">
<SearchToggle />
<Notifications />
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./public-layout";
@@ -0,0 +1,14 @@
import { Outlet } from "react-router-dom";
export const PublicLayout = () => {
return (
<div className="min-h-screen flex items-center justify-center px-4 py-6">
<div className="bg-ui-bg-base text-ui-fg-subtle w-[520px] px-16 py-20 rounded-[32px] shadow-elevation-modal flex flex-col gap-y-12 items-center">
<div className="w-24 h-24 rounded-3xl bg-ui-bg-subtle shadow-elevation-card-hover"></div>
<div className="w-full">
<Outlet />
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { Search } from "./search"
@@ -0,0 +1,245 @@
import { MagnifyingGlass } from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import * as Dialog from "@radix-ui/react-dialog"
import { Command } from "cmdk"
import {
ComponentPropsWithoutRef,
ElementRef,
HTMLAttributes,
forwardRef,
useMemo,
} from "react"
import { useTranslation } from "react-i18next"
import { useSearch } from "../../providers/search-provider"
export const Search = () => {
const { open, onOpenChange } = useSearch()
const links = useLinks()
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{links.map((group) => {
return (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((item) => {
return (
<CommandItem key={item.label}>
<span>{item.label}</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
</CommandList>
</CommandDialog>
)
}
type CommandItemProps = {
label: string
}
type CommandGroupProps = {
title: string
items: CommandItemProps[]
}
const useLinks = (): CommandGroupProps[] => {
const { t } = useTranslation()
return useMemo(
() => [
{
title: "Pages",
items: [
{
label: t("products.domain"),
},
{
label: t("categories.domain"),
},
{
label: t("collections.domain"),
},
{
label: t("giftCards.domain"),
},
{
label: t("orders.domain"),
},
{
label: t("draftOrders.domain"),
},
{
label: t("customers.domain"),
},
{
label: t("customerGroups.domain"),
},
{
label: t("discounts.domain"),
},
{
label: t("pricing.domain"),
},
],
},
{
title: "Settings",
items: [
{
label: t("profile.domain"),
},
{
label: t("store.domain"),
},
{
label: t("users.domain"),
},
],
},
],
[t]
)
}
const CommandPalette = forwardRef<
ElementRef<typeof Command>,
ComponentPropsWithoutRef<typeof Command>
>(({ className, ...props }, ref) => (
<Command
ref={ref}
className={clx(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
))
Command.displayName = Command.displayName
interface CommandDialogProps extends Dialog.DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog.Root {...props}>
<Dialog.Portal>
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</CommandPalette>
<div className="border-t px-4 pb-4 pt-3"></div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
const CommandInput = forwardRef<
ElementRef<typeof Command.Input>,
ComponentPropsWithoutRef<typeof Command.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlass className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
ref={ref}
className={clx(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = Command.Input.displayName
const CommandList = forwardRef<
ElementRef<typeof Command.List>,
ComponentPropsWithoutRef<typeof Command.List>
>(({ className, ...props }, ref) => (
<Command.List
ref={ref}
className={clx(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
className
)}
{...props}
/>
))
CommandList.displayName = Command.List.displayName
const CommandEmpty = forwardRef<
ElementRef<typeof Command.Empty>,
ComponentPropsWithoutRef<typeof Command.Empty>
>((props, ref) => (
<Command.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = Command.Empty.displayName
const CommandGroup = forwardRef<
ElementRef<typeof Command.Group>,
ComponentPropsWithoutRef<typeof Command.Group>
>(({ className, ...props }, ref) => (
<Command.Group
ref={ref}
className={clx(
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden px-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
className
)}
{...props}
/>
))
CommandGroup.displayName = Command.Group.displayName
const CommandSeparator = forwardRef<
ElementRef<typeof Command.Separator>,
ComponentPropsWithoutRef<typeof Command.Separator>
>(({ className, ...props }, ref) => (
<Command.Separator
ref={ref}
className={clx("bg-border -mx-1 h-px", className)}
{...props}
/>
))
CommandSeparator.displayName = Command.Separator.displayName
const CommandItem = forwardRef<
ElementRef<typeof Command.Item>,
ComponentPropsWithoutRef<typeof Command.Item>
>(({ className, ...props }, ref) => (
<Command.Item
ref={ref}
className={clx(
"aria-selected:bg-accent aria-selected:text-accent-foreground txt-compact-small relative flex cursor-default select-none items-center rounded-sm p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = Command.Item.displayName
const CommandShortcut = ({
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={clx(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
@@ -0,0 +1,28 @@
import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import Backend, { type HttpBackendOptions } from "i18next-http-backend"
import { initReactI18next } from "react-i18next"
import { Language } from "./types"
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init<HttpBackendOptions>({
fallbackLng: "en",
debug: process.env.NODE_ENV === "development",
interpolation: {
escapeValue: false,
},
})
export const languages: Language[] = [
{
code: "en",
display_name: "English",
ltr: true,
},
]
export default i18n
@@ -0,0 +1,13 @@
import en from "../../public/locales/en/translation.json"
const resources = {
translation: en,
} as const
export type Resources = typeof resources
export type Language = {
code: string
display_name: string
ltr: boolean
}
+7
View File
@@ -0,0 +1,7 @@
import { Resources } from "./i18n/types";
declare module "i18next" {
interface CustomTypeOptions {
resources: Resources;
}
}
@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: "Inter";
font-weight: 400;
src: url("./assets/fonts/Inter-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Inter";
font-weight: 500;
src: url("./assets/fonts/Inter-Medium.ttf") format("truetype");
}
@font-face {
font-family: "Roboto Mono";
font-weight: 400;
src: url("./assets/fonts/RobotoMono-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Roboto Mono";
font-weight: 500;
src: url("./assets/fonts/RobotoMono-Medium.ttf") format("truetype");
}
:root {
@apply bg-ui-bg-subtle text-ui-fg-base;
}
}
@@ -0,0 +1,17 @@
import Medusa from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 90000,
retry: 1,
},
},
})
export const medusa = new Medusa({
baseUrl: "http://localhost:9000",
maxRetries: 3,
})
@@ -0,0 +1,11 @@
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./app.js"
import "./i18n/config.js"
import "./index.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+39
View File
@@ -0,0 +1,39 @@
declare module "medusa-admin:widgets/*" {
const widgets: { Component: () => JSX.Element }[]
export default {
widgets,
}
}
declare module "medusa-admin:routes/links" {
const links: { path: string; label: string; icon?: React.ComponentType }[]
export default {
links,
}
}
declare module "medusa-admin:routes/pages" {
const pages: { path: string; file: string }[]
export default {
pages,
}
}
declare module "medusa-admin:settings/cards" {
const cards: { path: string; label: string; description: string }[]
export default {
cards,
}
}
declare module "medusa-admin:settings/pages" {
const pages: { path: string; file: string }[]
export default {
pages,
}
}
@@ -0,0 +1,10 @@
import { AdminAuthRes, User } from "@medusajs/medusa"
import { createContext } from "react"
type AuthContextValue = {
login: (email: string, password: string) => Promise<AdminAuthRes>
user: Omit<User, "password_hash"> | null
isLoading: boolean
}
export const AuthContext = createContext<AuthContextValue | null>(null)
@@ -0,0 +1,24 @@
import { useAdminGetSession, useAdminLogin } from "medusa-react"
import { PropsWithChildren } from "react"
import { AuthContext } from "./auth-context"
export const AuthProvider = ({ children }: PropsWithChildren) => {
const { mutateAsync: loginMutation } = useAdminLogin()
const { user, isLoading } = useAdminGetSession()
const login = async (email: string, password: string) => {
return await loginMutation({ email, password })
}
return (
<AuthContext.Provider
value={{
login,
user: user ?? null,
isLoading,
}}
>
{children}
</AuthContext.Provider>
)
}
@@ -0,0 +1,2 @@
export * from "./auth-provider";
export * from "./use-auth";
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext } from "./auth-context";
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
@@ -0,0 +1,8 @@
import { createContext } from "react";
import { Feature } from "./types";
type FeatureContextValue = {
isFeatureEnabled: (feature: Feature) => boolean;
};
export const FeatureContext = createContext<FeatureContextValue | null>(null);
@@ -0,0 +1,33 @@
import { useAdminStore } from "medusa-react";
import { PropsWithChildren, useEffect, useState } from "react";
import { FeatureContext } from "./feature-context";
import { Feature } from "./types";
export const FeatureProvider = ({ children }: PropsWithChildren) => {
const { store, isLoading } = useAdminStore();
const [features, setFeatures] = useState<Feature[]>([]);
useEffect(() => {
if (!store || isLoading) {
return;
}
const flags = store.feature_flags
.filter((f) => f.value === true)
.map((f) => f.key);
const modules = store.modules.map((m) => m.module);
const enabled = flags.concat(modules);
setFeatures(enabled as Feature[]);
}, [store, isLoading]);
function isFeatureEnabled(feature: Feature) {
return features.includes(feature);
}
return (
<FeatureContext.Provider value={{ isFeatureEnabled }}>
{children}
</FeatureContext.Provider>
);
};
@@ -0,0 +1,2 @@
export { FeatureProvider } from "./feature-provider";
export { useFeature } from "./use-feature";
@@ -0,0 +1,16 @@
const featureFlags = [
"analytics",
"order_editing",
"product_categories",
"publishable_api_keys",
"sales_channels",
"tax_inclusive_pricing",
] as const;
type FeatureFlag = (typeof featureFlags)[number];
const modules = ["inventory"] as const;
type Module = (typeof modules)[number];
export type Feature = FeatureFlag | Module;
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { FeatureContext } from "./feature-context";
export const useFeature = () => {
const context = useContext(FeatureContext);
if (context === null) {
throw new Error("useFeature must be used within a FeatureProvider");
}
return context;
};
@@ -0,0 +1 @@
export * from "./router-provider";
@@ -0,0 +1,319 @@
import {
RouterProvider as Provider,
RouteObject,
createBrowserRouter,
} from "react-router-dom"
import { RequireAuth } from "../../components/authentication/require-auth"
import { AppLayout } from "../../components/layout/app-layout"
import { PublicLayout } from "../../components/layout/public-layout"
import { AdminProductsRes } from "@medusajs/medusa"
import routes from "medusa-admin:routes/pages"
import settings from "medusa-admin:settings/pages"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { SearchProvider } from "../search-provider"
const routeExtensions: RouteObject[] = routes.pages.map((ext) => {
return {
path: ext.path,
async lazy() {
const { default: Component } = await import(/* @vite-ignore */ ext.file)
return { Component }
},
}
})
const settingsExtensions: RouteObject[] = settings.pages.map((ext) => {
return {
path: `/settings${ext.path}`,
async lazy() {
const { default: Component } = await import(/* @vite-ignore */ ext.file)
return { Component }
},
}
})
const router = createBrowserRouter([
{
element: <PublicLayout />,
children: [
{
path: "/login",
lazy: () => import("../../routes/login"),
},
],
},
{
element: (
<RequireAuth>
<SearchProvider>
<AppLayout />
</SearchProvider>
</RequireAuth>
),
errorElement: <ErrorBoundary />,
children: [
{
path: "/",
lazy: () => import("../../routes/home"),
},
{
path: "/orders",
children: [
{
index: true,
lazy: () => import("../../routes/orders/list"),
},
{
path: ":id",
lazy: () => import("../../routes/orders/details"),
},
],
},
{
path: "/draft-orders",
children: [
{
index: true,
lazy: () => import("../../routes/draft-orders/list"),
},
{
path: ":id",
lazy: () => import("../../routes/draft-orders/details"),
},
],
},
{
path: "/products",
handle: {
crumb: () => "Products",
},
children: [
{
index: true,
lazy: () => import("../../routes/products/views/product-list"),
},
{
path: ":id",
lazy: () => import("../../routes/products/views/product-details"),
handle: {
crumb: (data: AdminProductsRes) => data.product.title,
},
},
],
},
{
path: "/categories",
children: [
{
index: true,
lazy: () => import("../../routes/categories/list"),
},
{
path: ":id",
lazy: () => import("../../routes/categories/details"),
},
],
},
{
path: "/collections",
children: [
{
index: true,
lazy: () => import("../../routes/collections/list"),
},
{
path: ":id",
lazy: () => import("../../routes/collections/details"),
},
],
},
{
path: "/customers",
children: [
{
index: true,
lazy: () => import("../../routes/customers/list"),
},
{
path: ":id",
lazy: () => import("../../routes/customers/details"),
},
],
},
{
path: "/customer-groups",
children: [
{
index: true,
lazy: () => import("../../routes/customer-groups/list"),
},
{
path: ":id",
lazy: () => import("../../routes/customer-groups/details"),
},
],
},
{
path: "/gift-cards",
children: [
{
index: true,
lazy: () => import("../../routes/gift-cards/list"),
},
{
path: ":id",
lazy: () => import("../../routes/gift-cards/details"),
},
],
},
{
path: "/inventory",
lazy: () => import("../../routes/inventory/list"),
},
{
path: "/discounts",
children: [
{
index: true,
lazy: () => import("../../routes/discounts/list"),
},
{
path: ":id",
lazy: () => import("../../routes/discounts/details"),
},
],
},
{
path: "/pricing",
children: [
{
index: true,
lazy: () => import("../../routes/pricing/list"),
},
{
path: ":id",
lazy: () => import("../../routes/pricing/details"),
},
],
},
{
path: "/settings",
handle: {
crumb: () => "Settings",
},
children: [
{
index: true,
lazy: () => import("../../routes/settings"),
},
{
path: "profile",
lazy: () => import("../../routes/profile/views/profile-details"),
handle: {
crumb: () => "Profile",
},
},
{
path: "store",
lazy: () => import("../../routes/store/views/store-details"),
handle: {
crumb: () => "Store",
},
},
{
path: "locations",
lazy: () => import("../../routes/locations/list"),
},
{
path: "regions",
handle: {
crumb: () => "Regions",
},
children: [
{
index: true,
lazy: () => import("../../routes/regions/views/region-list"),
},
{
path: ":id",
lazy: () => import("../../routes/regions/views/region-details"),
},
],
},
{
path: "users",
lazy: () => import("../../routes/users"),
handle: {
crumb: () => "Users",
},
},
{
path: "currencies",
lazy: () =>
import("../../routes/currencies/views/currencies-details"),
handle: {
crumb: () => "Currencies",
},
},
{
path: "taxes",
handle: {
crumb: () => "Taxes",
},
children: [
{
index: true,
lazy: () => import("../../routes/taxes/views/tax-list"),
},
{
path: ":id",
lazy: () => import("../../routes/taxes/views/tax-details"),
},
],
},
{
path: "sales-channels",
handle: {
crumb: () => "Sales Channels",
},
children: [
{
index: true,
lazy: () =>
import(
"../../routes/sales-channels/views/sales-channel-list"
),
},
{
path: ":id",
lazy: () =>
import(
"../../routes/sales-channels/views/sales-channel-details"
),
},
],
},
{
path: "api-key-management",
lazy: () => import("../../routes/api-key-management"),
handle: {
crumb: () => "API Key Management",
},
},
...settingsExtensions,
],
},
...routeExtensions,
],
},
{
path: "*",
lazy: () => import("../../routes/no-match"),
},
])
export const RouterProvider = () => {
return <Provider router={router} />
}
@@ -0,0 +1,2 @@
export { SearchProvider } from "./search-provider"
export { useSearch } from "./use-search"
@@ -0,0 +1,9 @@
import { createContext } from "react"
type SearchContextValue = {
open: boolean
onOpenChange: (open: boolean) => void
toggleSearch: () => void
}
export const SearchContext = createContext<SearchContextValue | null>(null)
@@ -0,0 +1,38 @@
import { PropsWithChildren, useEffect, useState } from "react"
import { Search } from "../../components/search"
import { SearchContext } from "./search-context"
export const SearchProvider = ({ children }: PropsWithChildren) => {
const [open, setOpen] = useState(false)
const toggleSearch = () => {
setOpen(!open)
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("keydown", onKeyDown)
}
}, [])
return (
<SearchContext.Provider
value={{
open,
onOpenChange: setOpen,
toggleSearch,
}}
>
{children}
<Search />
</SearchContext.Provider>
)
}
@@ -0,0 +1,10 @@
import { useContext } from "react"
import { SearchContext } from "./search-context"
export const useSearch = () => {
const context = useContext(SearchContext)
if (!context) {
throw new Error("useSearch must be used within a SearchProvider")
}
return context
}
@@ -0,0 +1,3 @@
export type { Theme } from "./theme-context";
export * from "./theme-provider";
export * from "./use-theme";
@@ -0,0 +1,10 @@
import { createContext } from "react";
export type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
export const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -0,0 +1,54 @@
import { PropsWithChildren, useEffect, useState } from "react";
import { Theme, ThemeContext } from "./theme-context";
const THEME_KEY = "medusa_admin_theme";
export const ThemeProvider = ({ children }: PropsWithChildren) => {
const [state, setState] = useState<Theme>(
(localStorage?.getItem(THEME_KEY) as Theme) || "light"
);
const setTheme = (theme: Theme) => {
localStorage.setItem(THEME_KEY, theme);
setState(theme);
};
useEffect(() => {
const html = document.querySelector("html");
if (html) {
/**
* Temporarily disable transitions to prevent
* the theme change from flashing.
*/
const css = document.createElement("style");
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}`
)
);
document.head.appendChild(css);
html.classList.remove(state === "light" ? "dark" : "light");
html.classList.add(state);
/**
* Re-enable transitions after the theme has been set,
* and force the browser to repaint.
*/
window.getComputedStyle(css).opacity;
document.head.removeChild(css);
}
}, [state]);
return (
<ThemeContext.Provider value={{ theme: state, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { useContext } from "react";
import { ThemeContext } from "./theme-context";
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
@@ -0,0 +1,368 @@
import { InformationCircle } from "@medusajs/icons"
import { PublishableApiKey, SalesChannel } from "@medusajs/medusa"
import {
Button,
Checkbox,
Container,
FocusModal,
Heading,
Hint,
Input,
Label,
Table,
Text,
clx,
} from "@medusajs/ui"
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminCreatePublishableApiKey,
useAdminPublishableApiKeys,
useAdminSalesChannels,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../components/common/form"
export const ApiKeyManagement = () => {
const [showCreateModal, setShowCreateModal] = useState(false)
const { publishable_api_keys, isLoading, isError, error } =
useAdminPublishableApiKeys()
const columns = useColumns()
const table = useReactTable({
data: publishable_api_keys || [],
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
const { t } = useTranslation()
// TODO: Move to loading.tsx and set as Suspense fallback for the route
if (isLoading) {
return <div>Loading</div>
}
// TODO: Move to error.tsx and set as ErrorBoundary for the route
if (isError || !publishable_api_keys) {
const err = error ? JSON.parse(JSON.stringify(error)) : null
return (
<div>
{(err as Error & { status: number })?.status === 404 ? (
<div>Not found</div>
) : (
<div>Something went wrong!</div>
)}
</div>
)
}
const hasData = publishable_api_keys.length !== 0
return (
<div className="flex flex-col gap-y-2">
<Container className="p-0">
<div className="px-8 py-6 pb-4">
<Heading>{t("apiKeyManagement.domain")}</Heading>
</div>
<div className="border-ui-border-base border-y">
{hasData ? (
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="flex flex-col items-center py-24">
<div className="flex flex-col items-center gap-y-6">
<div className="flex flex-col items-center gap-y-2">
<InformationCircle />
<Text weight="plus" size="small" leading="compact">
{t("general.noRecordsFound")}
</Text>
<Text size="small" className="text-ui-fg-muted">
{t("apiKeyManagement.createAPublishableApiKey")}
</Text>
</div>
<Button
variant="secondary"
onClick={() => setShowCreateModal(!showCreateModal)}
>
{t("apiKeyManagement.createKey")}
</Button>
</div>
</div>
)}
</div>
<div className="h-[72px]"></div>
</Container>
<CreatePublishableApiKey
open={showCreateModal}
onOpenChange={setShowCreateModal}
/>
</div>
)
}
const columnHelper = createColumnHelper<PublishableApiKey>()
const useColumns = () => {
const { t } = useTranslation()
const columns = useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.accessor("id", {
header: "ID",
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
return columns
}
const CreatePublishableApiKeySchema = zod.object({
title: zod.string().min(1),
sales_channel_ids: zod.array(zod.string()).min(1),
})
type CreatePublishableApiKeySchema = zod.infer<
typeof CreatePublishableApiKeySchema
>
type CreatePublishableApiKeyProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
const salesChannelColumnHelper = createColumnHelper<SalesChannel>()
const useSalesChannelColumns = () => {
const { t } = useTranslation()
const columns = useMemo(
() => [
salesChannelColumnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
salesChannelColumnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
salesChannelColumnHelper.accessor("description", {
header: t("fields.description"),
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
return columns
}
const CreatePublishableApiKey = (props: CreatePublishableApiKeyProps) => {
const form = useForm<CreatePublishableApiKeySchema>({
defaultValues: {
title: "",
sales_channel_ids: [],
},
})
const { mutateAsync } = useAdminCreatePublishableApiKey()
const { sales_channels, isLoading, isError, error } = useAdminSalesChannels()
const columns = useSalesChannelColumns()
const table = useReactTable({
data: sales_channels || [],
columns: columns,
getCoreRowModel: getCoreRowModel(),
})
const onSubmit = form.handleSubmit(async ({ title, sales_channel_ids }) => {
await mutateAsync({
title,
})
})
const { t } = useTranslation()
return (
<FocusModal {...props}>
<Form {...form}>
<FocusModal.Content>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<Button variant="secondary">{t("general.cancel")}</Button>
<Button type="submit">Publish API Key</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center py-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-4">
<div className="flex flex-col gap-y-4">
<Heading>Create API Key</Heading>
<Text size="small" className="text-ui-fg-subtle">
Create and manage API keys. API keys are used to limit the
scope of requests to specific sales channels.
</Text>
</div>
<div className="flex flex-col gap-y-8">
<div className="grid grid-cols-2">
<Form.Field
control={form.control}
name="title"
render={({ field }) => (
<Form.Item>
<Form.Label>{t("fields.title")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
</div>
<div>
<Label weight="plus">Sales Channels</Label>
<Hint></Hint>
<Container className="overflow-hidden p-0">
<div className="px-8 pb-4 pt-6">
<Heading level="h2">Sales Channels</Heading>
</div>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg last-of-type:border-b-0 [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</Container>
</div>
</div>
</div>
</FocusModal.Body>
</FocusModal.Content>
</Form>
</FocusModal>
)
}
@@ -0,0 +1 @@
export { ApiKeyManagement as Component } from "./api-key-management"
@@ -0,0 +1,28 @@
import { Container, Heading } from "@medusajs/ui";
import after from "medusa-admin:widgets/product_category/details/after";
import before from "medusa-admin:widgets/product_category/details/before";
export const CategoryDetails = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
<Container>
<Heading>Category</Heading>
</Container>
{after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
</div>
);
};
@@ -0,0 +1 @@
export { CategoryDetails as Component } from "./details";
@@ -0,0 +1 @@
export { CategoriesList as Component } from "./list";
@@ -0,0 +1,28 @@
import { Container, Heading } from "@medusajs/ui";
import after from "medusa-admin:widgets/product_category/list/after";
import before from "medusa-admin:widgets/product_category/list/before";
export const CategoriesList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
<Container>
<Heading>Categories</Heading>
</Container>
{after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
);
})}
</div>
);
};
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CollectionDetails = () => {
return (
<div>
<Container>
<Heading>Collection</Heading>
</Container>
</div>
);
};
@@ -0,0 +1 @@
export { CollectionDetails as Component } from "./details";
@@ -0,0 +1 @@
export { CollectionsList as Component } from "./list";
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CollectionsList = () => {
return (
<div>
<Container>
<Heading>Collections</Heading>
</Container>
</div>
);
};
@@ -0,0 +1,159 @@
import { Store } from "@medusajs/medusa"
import { Button, Drawer, Heading, Select } from "@medusajs/ui"
import { useAdminUpdateStore } from "medusa-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../components/common/form"
const EditCurrenciesDetailsSchema = zod.object({
default_currency_code: zod.string(),
})
type EditCurrenciesDetailsDrawerProps = {
store: Store
}
export const EditCurrenciesDetailsDrawer = ({
store,
}: EditCurrenciesDetailsDrawerProps) => {
const [open, setOpen] = useState(false)
const [selectOpen, setSelectOpen] = useState(false)
const { t } = useTranslation()
const { mutateAsync } = useAdminUpdateStore()
const form = useForm<zod.infer<typeof EditCurrenciesDetailsSchema>>({
defaultValues: {
default_currency_code: store.default_currency_code,
},
})
const sortedCurrencies = store.currencies.sort((a, b) => {
if (a.code === store.default_currency_code) {
return -1
}
if (b.code === store.default_currency_code) {
return 1
}
return a.code.localeCompare(b.code)
})
const onOpenChange = (open: boolean) => {
if (!open) {
form.reset()
/**
* We need to close the select when the drawer closes.
* Otherwise it may lead to `pointer-events: none` being applied to the body.
*/
setSelectOpen(false)
}
setOpen(open)
}
const onSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
default_currency_code: values.default_currency_code,
},
{
onSuccess: ({ store }) => {
form.reset({
default_currency_code: store.default_currency_code,
})
onOpenChange(false)
},
onError: (err) => {
console.log(err)
},
}
)
})
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Trigger asChild>
<Button variant="secondary">
{t("currencies.editCurrencyDetails")}
</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("currencies.editCurrencyDetails")}</Heading>
</Drawer.Header>
<Drawer.Body>
<Form {...form}>
<form>
<Form.Field
name="default_currency_code"
control={form.control}
render={({ field: { ref, ...field } }) => {
return (
<Form.Item className="gap-y-2">
<Form.Label>{t("currencies.defaultCurrency")}</Form.Label>
<div>
<Form.Control>
<Select
{...field}
open={selectOpen}
onOpenChange={setSelectOpen}
value={field.value}
onValueChange={field.onChange}
size="small"
>
<Select.Trigger ref={ref}>
<Select.Value>
<span>
<span className="txt-compact-small-plus uppercase">
{field.value}
</span>{" "}
{
sortedCurrencies.find(
(curr) => curr.code === field.value
)?.name
}
</span>
</Select.Value>
</Select.Trigger>
<Select.Content>
{sortedCurrencies.map((curr) => (
<Select.Item key={curr.code} value={curr.code}>
<span>
<span className="txt-compact-small-plus uppercase">
{curr.code}
</span>{" "}
{curr.name}
</span>
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</div>
<Form.Hint>
{t("currencies.defaultCurrencyHint")}
</Form.Hint>
</Form.Item>
)
}}
/>
</form>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">{t("general.cancel")}</Button>
</Drawer.Close>
<Button onClick={onSubmit}>{t("general.save")}</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
)
}
@@ -0,0 +1 @@
export * from "./edit-currencies-details-drawer"
@@ -0,0 +1,488 @@
import {
BuildingTax,
CurrencyDollar,
EllipsisHorizontal,
} from "@medusajs/icons"
import { Currency, Store } from "@medusajs/medusa"
import {
Badge,
Button,
Checkbox,
CommandBar,
Container,
DropdownMenu,
FocusModal,
Heading,
IconButton,
Table,
Text,
Tooltip,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminCurrencies,
useAdminStore,
useAdminUpdateStore,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { EditCurrenciesDetailsDrawer } from "../../components/edit-currencies-details-drawer"
export const CurrenciesDetails = () => {
const { t } = useTranslation()
const { store, isLoading } = useAdminStore()
if (isLoading || !store) {
return <div>Loading...</div>
}
return (
<div className="flex flex-col gap-y-2">
<Container className="p-0">
<div className="flex items-center justify-between px-8 py-6">
<div>
<Heading>{t("currencies.domain")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("currencies.manageTheCurrencies")}
</Text>
</div>
<EditCurrenciesDetailsDrawer store={store} />
</div>
<div className="grid grid-cols-2 border-t px-8 py-6">
<Text size="small" leading="compact" weight="plus">
{t("currencies.defaultCurrency")}
</Text>
<div className="flex items-center gap-x-2">
<Badge rounded="full" className="uppercase">
{store.default_currency_code}
</Badge>
<Text size="small" leading="compact">
{store.default_currency.name}
</Text>
</div>
</div>
</Container>
<StoreCurrencySection store={store} />
</div>
)
}
type StoreCurrenciesSectionProps = {
store: Store
}
const PAGE_SIZE = 20
const StoreCurrencySection = ({ store }: StoreCurrenciesSectionProps) => {
const [addModalOpen, setAddModalOpen] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { mutateAsync } = useAdminUpdateStore()
const prompt = usePrompt()
const { t } = useTranslation()
const pageCount = Math.ceil(store.currencies.length / PAGE_SIZE)
const columns = useStoreCurrencyColumns()
const table = useReactTable({
data: store.currencies,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
pageCount: pageCount,
state: {
rowSelection,
},
})
const onDeleteCurrencies = async () => {
const ids = Object.keys(rowSelection)
const result = await prompt({
title: t("general.areYouSure"),
description: t("currencies.removeCurrenciesWarning", {
count: ids.length,
}),
confirmText: t("general.remove"),
cancelText: t("general.cancel"),
})
if (!result) {
return
}
await mutateAsync({
currencies: store.currencies
.filter((c) => !ids.includes(c.code))
.map((c) => c.code),
})
}
return (
<Container className="p-0">
<div className="flex items-center justify-between px-8 pb-4 pt-6">
<Heading level="h2">Store Currencies</Heading>
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
className="gap-x-2"
onClick={() => setAddModalOpen(!addModalOpen)}
>
<CurrencyDollar className="text-ui-fg-subtle" />
<span>Add Currencies</span>
</DropdownMenu.Item>
<DropdownMenu.Item className="gap-x-2">
<BuildingTax className="text-ui-fg-subtle" />
<span>Tax Preferences</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
<div>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx("transition-fg", {
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
})}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
<Table.Pagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={store.currencies.length}
pageIndex={table.getState().pagination.pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={onDeleteCurrencies}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
<AddCurrenciesModal
store={store}
onOpenChange={setAddModalOpen}
open={addModalOpen}
/>
</Container>
)
}
const storeCurrencyColumnHelper = createColumnHelper<Currency>()
const useStoreCurrencyColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
storeCurrencyColumnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
storeCurrencyColumnHelper.accessor("code", {
header: t("fields.code"),
cell: ({ getValue }) => getValue().toUpperCase(),
}),
storeCurrencyColumnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
storeCurrencyColumnHelper.accessor("includes_tax", {
header: "Tax Inclusive Prices",
cell: ({ getValue }) => {
return getValue() ? t("general.enabled") : t("general.disabled")
},
}),
],
[t]
)
}
const CURRENCIES_PAGE_SIZE = 50
const AddCurrenciesModal = ({
store,
open,
onOpenChange,
}: {
store: Store
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: CURRENCIES_PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { currencies, count, isLoading } = useAdminCurrencies({
limit: CURRENCIES_PAGE_SIZE,
offset: pageIndex * CURRENCIES_PAGE_SIZE,
})
const columns = useCurrencyColumns()
const table = useReactTable({
data: currencies ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / CURRENCIES_PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
currencyCodes: store.currencies?.map((c) => c.code) ?? [],
},
})
const { t } = useTranslation()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<Button variant="secondary">{t("general.cancel")}</Button>
<Button>{t("general.save")}</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto">
<div className="w-full flex-1 overflow-y-auto">
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg last-of-type:border-b-0",
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
store.currencies
.map((c) => c.code)
?.includes(row.original.code),
},
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<div className="w-full border-t">
<Table.Pagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={CURRENCIES_PAGE_SIZE}
/>
</div>
</FocusModal.Body>
</FocusModal.Content>
</FocusModal>
)
}
const currencyColumnHelper = createColumnHelper<Currency>()
const useCurrencyColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
currencyColumnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row, table }) => {
const { currencyCodes } = table.options.meta as {
currencyCodes: string[]
}
const isAdded = currencyCodes.includes(row.original.code)
const isSelected = row.getIsSelected() || isAdded
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
if (isAdded) {
return (
<Tooltip
content={t("currencies.currencyAlreadyAdded")}
side="right"
>
{Component}
</Tooltip>
)
}
return Component
},
}),
currencyColumnHelper.accessor("code", {
header: t("fields.code"),
cell: ({ getValue }) => getValue().toUpperCase(),
}),
currencyColumnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
}
@@ -0,0 +1 @@
export { CurrenciesDetails as Component } from "./currencies-details"
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomerGroupDetails = () => {
return (
<div>
<Container>
<Heading>Customers</Heading>
</Container>
</div>
);
};
@@ -0,0 +1 @@
export { CustomerGroupDetails as Component } from "./details";
@@ -0,0 +1 @@
export { CustomerGroupsList as Component } from "./list";
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomerGroupsList = () => {
return (
<div>
<Container>
<Heading>Customer Groups</Heading>
</Container>
</div>
);
};
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomerDetails = () => {
return (
<div>
<Container>
<Heading>Customers</Heading>
</Container>
</div>
);
};
@@ -0,0 +1 @@
export { CustomerDetails as Component } from "./details";
@@ -0,0 +1 @@
export { CustomersList as Component } from "./list";
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomersList = () => {
return (
<div>
<Container>
<Heading>Customers</Heading>
</Container>
</div>
);
};
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const DiscountsDetails = () => {
return (
<div>
<Container>
<Heading>Discounts</Heading>
</Container>
</div>
);
};
@@ -0,0 +1 @@
export { DiscountsDetails as Component } from "./details";
@@ -0,0 +1 @@
export { DiscountsList as Component } from "./list";
@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const DiscountsList = () => {
return (
<div>
<Container>
<Heading>Discounts</Heading>
</Container>
</div>
);
};
@@ -0,0 +1,3 @@
export const DraftOrderDetails = () => {
return <div>Draft Order Details</div>;
};
@@ -0,0 +1 @@
export { DraftOrderDetails as Component } from "./details";
@@ -0,0 +1 @@
export { DraftOrderList as Component } from "./list";
@@ -0,0 +1,9 @@
import { Container, Heading } from "@medusajs/ui";
export const DraftOrderList = () => {
return (
<Container>
<Heading>Draft Orders</Heading>
</Container>
);
};
@@ -0,0 +1,3 @@
export const GiftCardDetails = () => {
return <div>Gift Card Details</div>;
};
@@ -0,0 +1 @@
export { GiftCardDetails as Component } from "./details";
@@ -0,0 +1 @@
export { GiftCardList as Component } from "./list";
@@ -0,0 +1,9 @@
import { Container, Heading } from "@medusajs/ui";
export const GiftCardList = () => {
return (
<Container>
<Heading>Gift Card List</Heading>
</Container>
);
};
@@ -0,0 +1,13 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
export const Home = () => {
const navigate = useNavigate();
// Currently, the home page simply redirects to the orders page
useEffect(() => {
navigate("/orders", { replace: true });
}, [navigate]);
return <div />;
};
@@ -0,0 +1 @@
export { Home as Component } from "./home";
@@ -0,0 +1 @@
export { InventoryList as Component } from "./list";
@@ -0,0 +1,9 @@
import { Container, Heading } from "@medusajs/ui";
export const InventoryList = () => {
return (
<Container>
<Heading>Inventory</Heading>
</Container>
);
};
@@ -0,0 +1 @@
export { LocationsList as Component } from "./list";

Some files were not shown because too many files have changed in this diff Show More