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:
@@ -0,0 +1 @@
|
||||
export * from "./require-auth";
|
||||
+25
@@ -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;
|
||||
};
|
||||
+45
@@ -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"
|
||||
+133
@@ -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"
|
||||
Reference in New Issue
Block a user