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,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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
+159
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./edit-currencies-details-drawer"
|
||||
+488
@@ -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
Reference in New Issue
Block a user